From 9ef699aca4a868c1aea13aa98e9f280f15b4687b Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 18:08:12 +0530 Subject: [PATCH 01/16] fix(auth): align GraphQL queries and mutations with backend schema - Update usergroups_list to select group_roles and member_count instead of roles/user_count - Fix usergroup_create mutation signature to top-level (name, description) - Fix userroles_upsert_group and userroles_upsert_account_group mutation arguments - Fix customroles_list query schema to traverse roles wrapper - Fix customroles_create mutation to accept structured permissions array - Add comprehensive unit tests in cmd/auth_test.go --- cmd/auth_assign_role.go | 22 ++++--- cmd/auth_groups.go | 73 +++++++++++++++------- cmd/auth_roles.go | 73 +++++++++++++++------- cmd/auth_test.go | 134 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 54 deletions(-) create mode 100644 cmd/auth_test.go diff --git a/cmd/auth_assign_role.go b/cmd/auth_assign_role.go index 4c8d2b9..fd882f7 100644 --- a/cmd/auth_assign_role.go +++ b/cmd/auth_assign_role.go @@ -27,17 +27,21 @@ var authAssignRoleCmd = &cobra.Command{ if accountID != "" { // Account-scoped role assignment req := client.NewRequest(` - mutation AssignAccountGroupRole($request: UserRolesUpsertAccountGroupInput!) { - userroles_upsert_account_group(request: $request) { + mutation AssignAccountGroupRole($role: auth_account_group_roles_upsert_one_input!) { + userroles_upsert_account_group(role: $role) { status message } } `) - req.Var("request", map[string]any{ - "group_id": groupID, - "role": role, - "account_id": accountID, + req.Var("role", map[string]any{ + "group_id": groupID, + "account_roles": []map[string]string{ + { + "account_id": accountID, + "role": role, + }, + }, }) var respData struct { @@ -59,14 +63,14 @@ var authAssignRoleCmd = &cobra.Command{ } else { // Tenant-level role assignment req := client.NewRequest(` - mutation AssignGroupRole($request: UserRolesUpsertGroupInput!) { - userroles_upsert_group(request: $request) { + mutation AssignGroupRole($role: auth_tenant_group_roles_upsert_one_input!) { + userroles_upsert_group(role: $role) { status message } } `) - req.Var("request", map[string]any{ + req.Var("role", map[string]any{ "group_id": groupID, "role": role, }) diff --git a/cmd/auth_groups.go b/cmd/auth_groups.go index 3a8e0e0..f8f1d06 100644 --- a/cmd/auth_groups.go +++ b/cmd/auth_groups.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "strings" "github.com/nudgebee/nbctl/pkg/client" @@ -26,8 +27,8 @@ var authGroupsListCmd = &cobra.Command{ id name description - roles - user_count + group_roles + member_count created_at } } @@ -37,12 +38,12 @@ var authGroupsListCmd = &cobra.Command{ var respData struct { UsergroupsList struct { Rows []struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Roles []string `json:"roles"` - UserCount int `json:"user_count"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + GroupRoles any `json:"group_roles"` + MemberCount int `json:"member_count"` + CreatedAt string `json:"created_at"` } `json:"rows"` } `json:"usergroups_list"` } @@ -51,6 +52,12 @@ var authGroupsListCmd = &cobra.Command{ return err } + type groupRoleItem struct { + Role string `json:"role"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + } + type groupRow struct { ID string `json:"id"` Name string `json:"name"` @@ -61,16 +68,32 @@ var authGroupsListCmd = &cobra.Command{ } var rows []groupRow for _, r := range respData.UsergroupsList.Rows { + var roleItems []groupRoleItem + switch v := r.GroupRoles.(type) { + case string: + _ = json.Unmarshal([]byte(v), &roleItems) + case []any: + b, _ := json.Marshal(v) + _ = json.Unmarshal(b, &roleItems) + } + + var roles []string + for _, item := range roleItems { + if item.Role != "" { + roles = append(roles, item.Role) + } + } + rolesStr := "-" - if len(r.Roles) > 0 { - rolesStr = strings.Join(r.Roles, ", ") + if len(roles) > 0 { + rolesStr = strings.Join(roles, ", ") } rows = append(rows, groupRow{ ID: r.ID, Name: r.Name, Description: r.Description, Roles: rolesStr, - UserCount: r.UserCount, + UserCount: r.MemberCount, CreatedAt: r.CreatedAt, }) } @@ -103,24 +126,20 @@ var authGroupsCreateCmd = &cobra.Command{ graphqlClient := client.NewClient() req := client.NewRequest(` - mutation CreateUserGroup($request: UserGroupCreateInput!) { - usergroup_create(request: $request) { + mutation CreateUserGroup($name: String!, $description: String) { + usergroup_create(name: $name, description: $description) { id - name - status } } `) - req.Var("request", map[string]any{ - "name": groupName, - "description": desc, - }) + req.Var("name", groupName) + if desc != "" { + req.Var("description", desc) + } var respData struct { UsergroupCreate struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` + ID string `json:"id"` } `json:"usergroup_create"` } @@ -128,7 +147,15 @@ var authGroupsCreateCmd = &cobra.Command{ return err } - format.GetFormat().Print(respData.UsergroupCreate) + format.GetFormat().Print(struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + }{ + ID: respData.UsergroupCreate.ID, + Name: groupName, + Status: "created", + }) return nil }, } diff --git a/cmd/auth_roles.go b/cmd/auth_roles.go index aa46e10..3ebf049 100644 --- a/cmd/auth_roles.go +++ b/cmd/auth_roles.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/nudgebee/nbctl/pkg/client" "github.com/nudgebee/nbctl/pkg/format" @@ -26,9 +27,11 @@ var authRolesListCmd = &cobra.Command{ value } customroles_list { - id - name - description + roles { + id + name + description + } } } `) @@ -38,10 +41,12 @@ var authRolesListCmd = &cobra.Command{ DisplayName string `json:"display_name"` Value string `json:"value"` } `json:"roles_list"` - CustomrolesList []struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` + CustomrolesList struct { + Roles []struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + } `json:"roles"` } `json:"customroles_list"` } @@ -76,7 +81,7 @@ var authRolesListCmd = &cobra.Command{ Description: "-", }) } - for _, cr := range respData.CustomrolesList { + for _, cr := range respData.CustomrolesList.Roles { rows = append(rows, roleRow{ Type: "Custom", Name: cr.Name, @@ -111,28 +116,44 @@ var authRolesCreateCmd = &cobra.Command{ graphqlClient := client.NewClient() + type customRolePermissionInput struct { + Module string `json:"module"` + Class string `json:"class"` + } + + var permInputs []customRolePermissionInput + for _, p := range permissions { + parts := strings.SplitN(p, ":", 2) + if len(parts) == 2 { + permInputs = append(permInputs, customRolePermissionInput{ + Module: parts[0], + Class: parts[1], + }) + } else { + permInputs = append(permInputs, customRolePermissionInput{ + Module: p, + }) + } + } + req := client.NewRequest(` - mutation CreateCustomRole($request: CustomRoleCreateInput!) { - customroles_create(request: $request) { + mutation CreateCustomRole($name: String!, $description: String, $permissions: [CustomRolePermissionInput!]) { + customroles_create(name: $name, description: $description, permissions: $permissions) { id - name - status } } `) - - input := map[string]any{ - "name": roleName, - "description": desc, - "permissions": permissions, + req.Var("name", roleName) + if desc != "" { + req.Var("description", desc) + } + if len(permInputs) > 0 { + req.Var("permissions", permInputs) } - req.Var("request", input) var respData struct { CustomrolesCreate struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` + ID string `json:"id"` } `json:"customroles_create"` } @@ -140,7 +161,15 @@ var authRolesCreateCmd = &cobra.Command{ return err } - format.GetFormat().Print(respData.CustomrolesCreate) + format.GetFormat().Print(struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + }{ + ID: respData.CustomrolesCreate.ID, + Name: roleName, + Status: "created", + }) return nil }, } diff --git a/cmd/auth_test.go b/cmd/auth_test.go new file mode 100644 index 0000000..2147285 --- /dev/null +++ b/cmd/auth_test.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/nudgebee/nbctl/pkg/testutil" +) + +func TestAuthGroupsList_Unit(t *testing.T) { + mockData := map[string]any{ + "usergroups_list": map[string]any{ + "rows": []map[string]any{ + { + "id": "ug-1", + "name": "DevOps", + "description": "DevOps Team", + "group_roles": `[{"role":"tenant_admin"}]`, + "member_count": 5, + "created_at": "2026-01-01T00:00:00Z", + }, + }, + }, + } + + got, err := testutil.RunWithSimpleGraphQL(mockData, rootCmd, []string{"auth", "groups", "list"}) + if err != nil { + t.Fatalf("auth groups list failed: %v", err) + } + if got == "" { + t.Fatalf("expected output, got empty string") + } + + expected := []string{"Group Name", "Assigned Roles", "DevOps", "tenant_admin"} + for _, exp := range expected { + if !strings.Contains(got, exp) { + t.Errorf("expected output to contain %q, got %q", exp, got) + } + } +} + +func TestAuthRolesList_Unit(t *testing.T) { + mockData := map[string]any{ + "roles_list": []map[string]any{ + { + "display_name": "Admin", + "value": "tenant_admin", + }, + }, + "customroles_list": map[string]any{ + "roles": []map[string]any{ + { + "id": "cr-1", + "name": "Security Auditor", + "description": "Read-only access to audit logs", + }, + }, + }, + } + + got, err := testutil.RunWithSimpleGraphQL(mockData, rootCmd, []string{"auth", "roles", "list"}) + if err != nil { + t.Fatalf("auth roles list failed: %v", err) + } + if got == "" { + t.Fatalf("expected output, got empty string") + } + + expected := []string{"Built-in", "Admin", "tenant_admin", "Custom", "Security Auditor"} + for _, exp := range expected { + if !strings.Contains(got, exp) { + t.Errorf("expected output to contain %q, got %q", exp, got) + } + } +} + +func TestAuthUsersList_Unit(t *testing.T) { + mockData := map[string]any{ + "users_list_by_tenant": map[string]any{ + "rows": []map[string]any{ + { + "id": "user-1", + "username": "alice@example.com", + "display_name": "Alice Smith", + "status": "active", + "created_at": "2026-01-01T00:00:00Z", + }, + }, + }, + } + + got, err := testutil.RunWithSimpleGraphQL(mockData, rootCmd, []string{"auth", "users", "list"}) + if err != nil { + t.Fatalf("auth users list failed: %v", err) + } + if got == "" { + t.Fatalf("expected output, got empty string") + } + + expected := []string{"Username", "Display Name", "alice@example.com", "Alice Smith"} + for _, exp := range expected { + if !strings.Contains(got, exp) { + t.Errorf("expected output to contain %q, got %q", exp, got) + } + } +} + +func TestAuthUsersGet_Unit(t *testing.T) { + mockData := map[string]any{ + "users_list_by_tenant": map[string]any{ + "rows": []map[string]any{ + { + "id": "user-1", + "username": "alice@example.com", + "display_name": "Alice Smith", + "status": "active", + "created_at": "2026-01-01T00:00:00Z", + }, + }, + }, + } + + got, err := testutil.RunWithSimpleGraphQL(mockData, rootCmd, []string{"auth", "users", "get", "alice@example.com"}) + if err != nil { + t.Fatalf("auth users get failed: %v", err) + } + if got == "" { + t.Fatalf("expected output, got empty string") + } + + if !strings.Contains(got, "alice@example.com") { + t.Errorf("expected output to contain alice@example.com, got %q", got) + } +} From a0ccaa89f8905daa302498cd383aba5fb3bcf6e7 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 18:08:19 +0530 Subject: [PATCH 02/16] fix(nubi): add tenant-wide fallback and support --account-id in query - Make account-id optional for nubi agents and nubi tools - Add automatic tenant-wide fallback (account_id: "") in ListAgents and ListTools when account-id access is denied - Wire --account-id flag to nubi query command using resolveAccountID --- cmd/nubi.go | 10 +++++++++- cmd/nubi_agents.go | 2 +- cmd/nubi_query.go | 7 ++++--- cmd/nubi_tools.go | 2 +- pkg/nubi/nubi.go | 24 ++++++++++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/cmd/nubi.go b/cmd/nubi.go index 0a18549..abb4244 100644 --- a/cmd/nubi.go +++ b/cmd/nubi.go @@ -600,6 +600,14 @@ func saveHistory(file string, history []string) error { } func initNubiClient(args []string) (*nubi.NubiClient, error) { + return initNubiClientWithAccountRequirement(args, true) +} + +func initNubiClientOptionalAccount(args []string) (*nubi.NubiClient, error) { + return initNubiClientWithAccountRequirement(args, false) +} + +func initNubiClientWithAccountRequirement(args []string, requireAccount bool) (*nubi.NubiClient, error) { var accountID string if len(args) > 0 { accountID = args[0] @@ -607,7 +615,7 @@ func initNubiClient(args []string) (*nubi.NubiClient, error) { accountID = viper.GetString("account-id") } - if accountID == "" { + if requireAccount && accountID == "" { return nil, fmt.Errorf("account-id is required, please provide it as an argument or set it in your config file") } diff --git a/cmd/nubi_agents.go b/cmd/nubi_agents.go index ab40b64..ec25ae9 100644 --- a/cmd/nubi_agents.go +++ b/cmd/nubi_agents.go @@ -20,7 +20,7 @@ var nubiAgentsCmd = &cobra.Command{ Long: `Display registered AI agents, descriptions, status, and assigned toolsets.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - nubiClient, err := initNubiClient(args) + nubiClient, err := initNubiClientOptionalAccount(args) if err != nil { return err } diff --git a/cmd/nubi_query.go b/cmd/nubi_query.go index 0b035b6..148f0b8 100644 --- a/cmd/nubi_query.go +++ b/cmd/nubi_query.go @@ -36,9 +36,9 @@ var nubiQueryCmd = &cobra.Command{ return fmt.Errorf("query cannot be empty") } - accountID := viper.GetString("account-id") - if accountID == "" { - return fmt.Errorf("account-id is required, please set it in your config file or pass via flag") + accountID, err := resolveAccountID(cmd) + if err != nil { + return err } username := viper.GetString("username") @@ -212,5 +212,6 @@ func (s *nubiQueryShell) poll(ctx context.Context) (string, string, error) { func init() { nubiQueryCmd.Flags().BoolVar(&nubiQueryAsync, "async", false, "Trigger query asynchronously without waiting for response") + nubiQueryCmd.Flags().String("account-id", "", "Account ID to execute the query against") nubiCmd.AddCommand(nubiQueryCmd) } diff --git a/cmd/nubi_tools.go b/cmd/nubi_tools.go index 967ae05..ef6f606 100644 --- a/cmd/nubi_tools.go +++ b/cmd/nubi_tools.go @@ -11,7 +11,7 @@ var nubiToolsCmd = &cobra.Command{ Long: `Display registered tools, descriptions, status, and tool types.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - nubiClient, err := initNubiClient(args) + nubiClient, err := initNubiClientOptionalAccount(args) if err != nil { return err } diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index ec1b786..760d15b 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -849,6 +849,18 @@ func (c *NubiClient) ListAgents() ([]AgentItem, error) { } if err := c.Client.Run(context.Background(), req, &respData); err != nil { + if c.AccountID != "" && strings.Contains(err.Error(), "user does not have access") { + reqFallback := client.NewRequest(` + query ListAgents { + ai_list_agents(request: {account_id: ""}) { + data + } + } + `) + if errFallback := c.Client.Run(context.Background(), reqFallback, &respData); errFallback == nil { + return respData.AiListAgents.Data, nil + } + } return nil, err } @@ -879,6 +891,18 @@ func (c *NubiClient) ListTools() ([]ToolItem, error) { } if err := c.Client.Run(context.Background(), req, &respData); err != nil { + if c.AccountID != "" && strings.Contains(err.Error(), "user does not have access") { + reqFallback := client.NewRequest(` + query ListTools { + ai_list_tools(request: {account_id: ""}) { + data + } + } + `) + if errFallback := c.Client.Run(context.Background(), reqFallback, &respData); errFallback == nil { + return respData.AiListTools.Data, nil + } + } return nil, err } From bc598fb7ea6e8d90be54b10aa969f0fb8b74f109 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:22:22 +0530 Subject: [PATCH 03/16] refactor(auth): use custom unmarshaler for group roles and trim permission strings - Implement UnmarshalJSON for groupRolesField to eliminate double-serialization - Trim whitespace and skip empty permission entries in customroles_create - Update auth unit tests covering both string and array group_roles --- cmd/auth_groups.go | 60 +++++++++++++++++++++++++++++----------------- cmd/auth_roles.go | 8 +++++-- cmd/auth_test.go | 10 +++++++- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/cmd/auth_groups.go b/cmd/auth_groups.go index f8f1d06..2e51bea 100644 --- a/cmd/auth_groups.go +++ b/cmd/auth_groups.go @@ -14,6 +14,37 @@ var authGroupsCmd = &cobra.Command{ Short: "Manage tenant user groups", } +type groupRoleItem struct { + Role string `json:"role"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` +} + +type groupRolesField []groupRoleItem + +func (g *groupRolesField) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + var items []groupRoleItem + if err := json.Unmarshal(data, &items); err == nil { + *g = items + return nil + } + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + if str == "" { + return nil + } + if err := json.Unmarshal([]byte(str), &items); err != nil { + return err + } + *g = items + return nil +} + var authGroupsListCmd = &cobra.Command{ Use: "list", Short: "List user groups with assigned roles and member count", @@ -38,12 +69,12 @@ var authGroupsListCmd = &cobra.Command{ var respData struct { UsergroupsList struct { Rows []struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - GroupRoles any `json:"group_roles"` - MemberCount int `json:"member_count"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + GroupRoles groupRolesField `json:"group_roles"` + MemberCount int `json:"member_count"` + CreatedAt string `json:"created_at"` } `json:"rows"` } `json:"usergroups_list"` } @@ -52,12 +83,6 @@ var authGroupsListCmd = &cobra.Command{ return err } - type groupRoleItem struct { - Role string `json:"role"` - EntityType string `json:"entity_type"` - EntityID string `json:"entity_id"` - } - type groupRow struct { ID string `json:"id"` Name string `json:"name"` @@ -68,17 +93,8 @@ var authGroupsListCmd = &cobra.Command{ } var rows []groupRow for _, r := range respData.UsergroupsList.Rows { - var roleItems []groupRoleItem - switch v := r.GroupRoles.(type) { - case string: - _ = json.Unmarshal([]byte(v), &roleItems) - case []any: - b, _ := json.Marshal(v) - _ = json.Unmarshal(b, &roleItems) - } - var roles []string - for _, item := range roleItems { + for _, item := range r.GroupRoles { if item.Role != "" { roles = append(roles, item.Role) } diff --git a/cmd/auth_roles.go b/cmd/auth_roles.go index 3ebf049..ce7cab6 100644 --- a/cmd/auth_roles.go +++ b/cmd/auth_roles.go @@ -123,11 +123,15 @@ var authRolesCreateCmd = &cobra.Command{ var permInputs []customRolePermissionInput for _, p := range permissions { + p = strings.TrimSpace(p) + if p == "" { + continue + } parts := strings.SplitN(p, ":", 2) if len(parts) == 2 { permInputs = append(permInputs, customRolePermissionInput{ - Module: parts[0], - Class: parts[1], + Module: strings.TrimSpace(parts[0]), + Class: strings.TrimSpace(parts[1]), }) } else { permInputs = append(permInputs, customRolePermissionInput{ diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 2147285..3186b2c 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -19,6 +19,14 @@ func TestAuthGroupsList_Unit(t *testing.T) { "member_count": 5, "created_at": "2026-01-01T00:00:00Z", }, + { + "id": "ug-2", + "name": "SRE", + "description": "SRE Team", + "group_roles": []map[string]any{{"role": "account_admin"}}, + "member_count": 3, + "created_at": "2026-01-02T00:00:00Z", + }, }, }, } @@ -31,7 +39,7 @@ func TestAuthGroupsList_Unit(t *testing.T) { t.Fatalf("expected output, got empty string") } - expected := []string{"Group Name", "Assigned Roles", "DevOps", "tenant_admin"} + expected := []string{"Group Name", "Assigned Roles", "DevOps", "tenant_admin", "SRE", "account_admin"} for _, exp := range expected { if !strings.Contains(got, exp) { t.Errorf("expected output to contain %q, got %q", exp, got) From 2c6da0e9666a2e6f39344a477c86085e6c69817e Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:28:24 +0530 Subject: [PATCH 04/16] fix(nubi): use isolated response struct for ListAgents and ListTools fallbacks - Avoid reusing primary respData variable during fallback calls to prevent partial unmarshaling side effects --- pkg/nubi/nubi.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index 760d15b..ac36936 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -857,8 +857,13 @@ func (c *NubiClient) ListAgents() ([]AgentItem, error) { } } `) - if errFallback := c.Client.Run(context.Background(), reqFallback, &respData); errFallback == nil { - return respData.AiListAgents.Data, nil + var respDataFallback struct { + AiListAgents struct { + Data []AgentItem `json:"data"` + } `json:"ai_list_agents"` + } + if errFallback := c.Client.Run(context.Background(), reqFallback, &respDataFallback); errFallback == nil { + return respDataFallback.AiListAgents.Data, nil } } return nil, err @@ -899,8 +904,13 @@ func (c *NubiClient) ListTools() ([]ToolItem, error) { } } `) - if errFallback := c.Client.Run(context.Background(), reqFallback, &respData); errFallback == nil { - return respData.AiListTools.Data, nil + var respDataFallback struct { + AiListTools struct { + Data []ToolItem `json:"data"` + } `json:"ai_list_tools"` + } + if errFallback := c.Client.Run(context.Background(), reqFallback, &respDataFallback); errFallback == nil { + return respDataFallback.AiListTools.Data, nil } } return nil, err From aef30804ff956bc7ea7e6b698f63b4e14341633e Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:30:19 +0530 Subject: [PATCH 05/16] fix(auth): add omitempty to Class in customRolePermissionInput and add unit test --- cmd/auth_roles.go | 2 +- cmd/auth_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/cmd/auth_roles.go b/cmd/auth_roles.go index ce7cab6..5cea6fb 100644 --- a/cmd/auth_roles.go +++ b/cmd/auth_roles.go @@ -118,7 +118,7 @@ var authRolesCreateCmd = &cobra.Command{ type customRolePermissionInput struct { Module string `json:"module"` - Class string `json:"class"` + Class string `json:"class,omitempty"` } var permInputs []customRolePermissionInput diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 3186b2c..75b2274 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -140,3 +140,31 @@ func TestAuthUsersGet_Unit(t *testing.T) { t.Errorf("expected output to contain alice@example.com, got %q", got) } } + +func TestAuthRolesCreate_Unit(t *testing.T) { + mockData := map[string]any{ + "customroles_create": map[string]any{ + "id": "cr-100", + }, + } + + got, err := testutil.RunWithSimpleGraphQL(mockData, rootCmd, []string{ + "auth", "roles", "create", "test-role", + "--description", "test role description", + "--permission", "events:read", + "--permission", "logs", + }) + if err != nil { + t.Fatalf("auth roles create failed: %v", err) + } + if got == "" { + t.Fatalf("expected output, got empty string") + } + + expected := []string{"cr-100", "test-role", "created"} + for _, exp := range expected { + if !strings.Contains(got, exp) { + t.Errorf("expected output to contain %q, got %q", exp, got) + } + } +} From 9f23479553fb9f712af07ea24d80c33c0f399feb Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:35:04 +0530 Subject: [PATCH 06/16] fix(auth,nubi): preserve unmarshal error on group roles and make fallback checks case-insensitive --- cmd/auth_groups.go | 24 +++++++++++++----------- cmd/auth_test.go | 38 ++++++++++++++++++++++++++++++++++++++ pkg/nubi/nubi.go | 4 ++-- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/cmd/auth_groups.go b/cmd/auth_groups.go index 2e51bea..6f16f6b 100644 --- a/cmd/auth_groups.go +++ b/cmd/auth_groups.go @@ -27,22 +27,24 @@ func (g *groupRolesField) UnmarshalJSON(data []byte) error { return nil } var items []groupRoleItem - if err := json.Unmarshal(data, &items); err == nil { + err := json.Unmarshal(data, &items) + if err == nil { *g = items return nil } + var str string - if err := json.Unmarshal(data, &str); err != nil { - return err - } - if str == "" { - return nil - } - if err := json.Unmarshal([]byte(str), &items); err != nil { - return err + if errStr := json.Unmarshal(data, &str); errStr == nil { + if str == "" { + return nil + } + if errArr := json.Unmarshal([]byte(str), &items); errArr == nil { + *g = items + return nil + } } - *g = items - return nil + + return err } var authGroupsListCmd = &cobra.Command{ diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 75b2274..f72966f 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "strings" "testing" @@ -168,3 +169,40 @@ func TestAuthRolesCreate_Unit(t *testing.T) { } } } + +func TestGroupRolesField_UnmarshalJSON(t *testing.T) { + // Test direct array + var g1 groupRolesField + if err := json.Unmarshal([]byte(`[{"role":"admin","entity_type":"tenant","entity_id":"t-1"}]`), &g1); err != nil { + t.Fatalf("unexpected error for direct array: %v", err) + } + if len(g1) != 1 || g1[0].Role != "admin" { + t.Errorf("unexpected content for direct array: %+v", g1) + } + + // Test stringified array + var g2 groupRolesField + if err := json.Unmarshal([]byte(`"[{\"role\":\"viewer\"}]"`), &g2); err != nil { + t.Fatalf("unexpected error for stringified array: %v", err) + } + if len(g2) != 1 || g2[0].Role != "viewer" { + t.Errorf("unexpected content for stringified array: %+v", g2) + } + + // Test empty string + var g3 groupRolesField + if err := json.Unmarshal([]byte(`""`), &g3); err != nil { + t.Fatalf("unexpected error for empty string: %v", err) + } + if len(g3) != 0 { + t.Errorf("expected 0 items for empty string, got %d", len(g3)) + } + + // Test invalid structure returns meaningful array unmarshal error + var g4 groupRolesField + if err := json.Unmarshal([]byte(`[123]`), &g4); err == nil { + t.Fatalf("expected error for invalid array element, got nil") + } else if !strings.Contains(err.Error(), "cannot unmarshal number") { + t.Errorf("expected error about number unmarshaling, got: %v", err) + } +} diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index ac36936..d1ec1fd 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -849,7 +849,7 @@ func (c *NubiClient) ListAgents() ([]AgentItem, error) { } if err := c.Client.Run(context.Background(), req, &respData); err != nil { - if c.AccountID != "" && strings.Contains(err.Error(), "user does not have access") { + if c.AccountID != "" && strings.Contains(strings.ToLower(err.Error()), "user does not have access") { reqFallback := client.NewRequest(` query ListAgents { ai_list_agents(request: {account_id: ""}) { @@ -896,7 +896,7 @@ func (c *NubiClient) ListTools() ([]ToolItem, error) { } if err := c.Client.Run(context.Background(), req, &respData); err != nil { - if c.AccountID != "" && strings.Contains(err.Error(), "user does not have access") { + if c.AccountID != "" && strings.Contains(strings.ToLower(err.Error()), "user does not have access") { reqFallback := client.NewRequest(` query ListTools { ai_list_tools(request: {account_id: ""}) { From 50f5c195626895e2c19333f2d7591d0709781ce5 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:38:03 +0530 Subject: [PATCH 07/16] fix(auth): validate that permission module is not empty --- cmd/auth_roles.go | 6 +++++- cmd/auth_test.go | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cmd/auth_roles.go b/cmd/auth_roles.go index 5cea6fb..75dde90 100644 --- a/cmd/auth_roles.go +++ b/cmd/auth_roles.go @@ -129,8 +129,12 @@ var authRolesCreateCmd = &cobra.Command{ } parts := strings.SplitN(p, ":", 2) if len(parts) == 2 { + module := strings.TrimSpace(parts[0]) + if module == "" { + return fmt.Errorf("invalid permission format %q: module cannot be empty", p) + } permInputs = append(permInputs, customRolePermissionInput{ - Module: strings.TrimSpace(parts[0]), + Module: module, Class: strings.TrimSpace(parts[1]), }) } else { diff --git a/cmd/auth_test.go b/cmd/auth_test.go index f72966f..8ef44ff 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -168,6 +168,17 @@ func TestAuthRolesCreate_Unit(t *testing.T) { t.Errorf("expected output to contain %q, got %q", exp, got) } } + + // Test invalid permission format (empty module) + _, errInvalid := testutil.RunWithSimpleGraphQL(mockData, rootCmd, []string{ + "auth", "roles", "create", "test-role", + "--permission", ":read", + }) + if errInvalid == nil { + t.Fatalf("expected error for empty permission module, got nil") + } else if !strings.Contains(errInvalid.Error(), "module cannot be empty") { + t.Errorf("expected 'module cannot be empty' error, got: %v", errInvalid) + } } func TestGroupRolesField_UnmarshalJSON(t *testing.T) { From b0223305b9f8f60cc7f63acebddb05a070502fed Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:42:30 +0530 Subject: [PATCH 08/16] fix(nubi,auth): add context to ListAgents and ListTools, and skip empty permission modules --- cmd/auth_roles.go | 5 +++-- cmd/auth_test.go | 17 ++++++++++------- cmd/nubi.go | 4 ++-- cmd/nubi_agents.go | 2 +- cmd/nubi_tools.go | 2 +- pkg/nubi/nubi.go | 18 ++++++++++++------ pkg/nubi/nubi_test.go | 4 ++-- 7 files changed, 31 insertions(+), 21 deletions(-) diff --git a/cmd/auth_roles.go b/cmd/auth_roles.go index 75dde90..bf389b3 100644 --- a/cmd/auth_roles.go +++ b/cmd/auth_roles.go @@ -130,12 +130,13 @@ var authRolesCreateCmd = &cobra.Command{ parts := strings.SplitN(p, ":", 2) if len(parts) == 2 { module := strings.TrimSpace(parts[0]) + class := strings.TrimSpace(parts[1]) if module == "" { - return fmt.Errorf("invalid permission format %q: module cannot be empty", p) + continue } permInputs = append(permInputs, customRolePermissionInput{ Module: module, - Class: strings.TrimSpace(parts[1]), + Class: class, }) } else { permInputs = append(permInputs, customRolePermissionInput{ diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 8ef44ff..1d8e4a3 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -169,15 +169,18 @@ func TestAuthRolesCreate_Unit(t *testing.T) { } } - // Test invalid permission format (empty module) - _, errInvalid := testutil.RunWithSimpleGraphQL(mockData, rootCmd, []string{ - "auth", "roles", "create", "test-role", + // Test permissions with empty strings and empty modules (should be skipped) + got2, err2 := testutil.RunWithSimpleGraphQL(mockData, rootCmd, []string{ + "auth", "roles", "create", "test-role-2", + "--permission", "", "--permission", ":read", + "--permission", "audit:view", }) - if errInvalid == nil { - t.Fatalf("expected error for empty permission module, got nil") - } else if !strings.Contains(errInvalid.Error(), "module cannot be empty") { - t.Errorf("expected 'module cannot be empty' error, got: %v", errInvalid) + if err2 != nil { + t.Fatalf("expected empty module and empty string to be skipped without error, got: %v", err2) + } + if !strings.Contains(got2, "test-role-2") { + t.Errorf("expected output to contain test-role-2, got %q", got2) } } diff --git a/cmd/nubi.go b/cmd/nubi.go index abb4244..0ee239b 100644 --- a/cmd/nubi.go +++ b/cmd/nubi.go @@ -375,7 +375,7 @@ func (s *nubiShell) handleSlashCommand(in string) { case "/bookmarks": s.handleBookmarkCommand(parts) case "/agents": - agents, err := s.nubiClient.ListAgents() + agents, err := s.nubiClient.ListAgents(context.Background()) if err != nil { fmt.Printf("Error listing agents: %v\n", err) return @@ -390,7 +390,7 @@ func (s *nubiShell) handleSlashCommand(in string) { }, }) case "/tools": - tools, err := s.nubiClient.ListTools() + tools, err := s.nubiClient.ListTools(context.Background()) if err != nil { fmt.Printf("Error listing tools: %v\n", err) return diff --git a/cmd/nubi_agents.go b/cmd/nubi_agents.go index ec25ae9..778b58e 100644 --- a/cmd/nubi_agents.go +++ b/cmd/nubi_agents.go @@ -25,7 +25,7 @@ var nubiAgentsCmd = &cobra.Command{ return err } - agents, err := nubiClient.ListAgents() + agents, err := nubiClient.ListAgents(cmd.Context()) if err != nil { return err } diff --git a/cmd/nubi_tools.go b/cmd/nubi_tools.go index ef6f606..ae3fe0b 100644 --- a/cmd/nubi_tools.go +++ b/cmd/nubi_tools.go @@ -16,7 +16,7 @@ var nubiToolsCmd = &cobra.Command{ return err } - tools, err := nubiClient.ListTools() + tools, err := nubiClient.ListTools(cmd.Context()) if err != nil { return err } diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index d1ec1fd..a0d1362 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -832,7 +832,10 @@ type AgentItem struct { Tools []string `json:"tools"` } -func (c *NubiClient) ListAgents() ([]AgentItem, error) { +func (c *NubiClient) ListAgents(ctx context.Context) ([]AgentItem, error) { + if ctx == nil { + ctx = context.Background() + } req := client.NewRequest(` query ListAgents($accountId: String!) { ai_list_agents(request: {account_id: $accountId}) { @@ -848,7 +851,7 @@ func (c *NubiClient) ListAgents() ([]AgentItem, error) { } `json:"ai_list_agents"` } - if err := c.Client.Run(context.Background(), req, &respData); err != nil { + if err := c.Client.Run(ctx, req, &respData); err != nil { if c.AccountID != "" && strings.Contains(strings.ToLower(err.Error()), "user does not have access") { reqFallback := client.NewRequest(` query ListAgents { @@ -862,7 +865,7 @@ func (c *NubiClient) ListAgents() ([]AgentItem, error) { Data []AgentItem `json:"data"` } `json:"ai_list_agents"` } - if errFallback := c.Client.Run(context.Background(), reqFallback, &respDataFallback); errFallback == nil { + if errFallback := c.Client.Run(ctx, reqFallback, &respDataFallback); errFallback == nil { return respDataFallback.AiListAgents.Data, nil } } @@ -879,7 +882,10 @@ type ToolItem struct { NBToolType string `json:"nb_tool_type"` } -func (c *NubiClient) ListTools() ([]ToolItem, error) { +func (c *NubiClient) ListTools(ctx context.Context) ([]ToolItem, error) { + if ctx == nil { + ctx = context.Background() + } req := client.NewRequest(` query ListTools($accountId: String!) { ai_list_tools(request: {account_id: $accountId}) { @@ -895,7 +901,7 @@ func (c *NubiClient) ListTools() ([]ToolItem, error) { } `json:"ai_list_tools"` } - if err := c.Client.Run(context.Background(), req, &respData); err != nil { + if err := c.Client.Run(ctx, req, &respData); err != nil { if c.AccountID != "" && strings.Contains(strings.ToLower(err.Error()), "user does not have access") { reqFallback := client.NewRequest(` query ListTools { @@ -909,7 +915,7 @@ func (c *NubiClient) ListTools() ([]ToolItem, error) { Data []ToolItem `json:"data"` } `json:"ai_list_tools"` } - if errFallback := c.Client.Run(context.Background(), reqFallback, &respDataFallback); errFallback == nil { + if errFallback := c.Client.Run(ctx, reqFallback, &respDataFallback); errFallback == nil { return respDataFallback.AiListTools.Data, nil } } diff --git a/pkg/nubi/nubi_test.go b/pkg/nubi/nubi_test.go index 060a40a..0654f1a 100644 --- a/pkg/nubi/nubi_test.go +++ b/pkg/nubi/nubi_test.go @@ -310,7 +310,7 @@ func TestNubiClient_ListAgents(t *testing.T) { c, teardown := newTestNubiClient(handler) defer teardown() - agents, err := c.ListAgents() + agents, err := c.ListAgents(context.Background()) assert.NoError(t, err) assert.Len(t, agents, 1) assert.Equal(t, "agent1", agents[0].Name) @@ -332,7 +332,7 @@ func TestNubiClient_ListTools(t *testing.T) { c, teardown := newTestNubiClient(handler) defer teardown() - tools, err := c.ListTools() + tools, err := c.ListTools(context.Background()) assert.NoError(t, err) assert.Len(t, tools, 1) assert.Equal(t, "tool1", tools[0].Name) From e9e49e9bdc49af90e7aed6b3877cdd40f984f5e3 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:46:24 +0530 Subject: [PATCH 09/16] refactor(nubi,auth): extract listWithAccountFallback helper and return specific unmarshal error --- cmd/auth_groups.go | 2 + cmd/auth_test.go | 8 ++++ pkg/nubi/nubi.go | 91 ++++++++++++++++--------------------------- pkg/nubi/nubi_test.go | 34 ++++++++++++++++ 4 files changed, 78 insertions(+), 57 deletions(-) diff --git a/cmd/auth_groups.go b/cmd/auth_groups.go index 6f16f6b..94712c3 100644 --- a/cmd/auth_groups.go +++ b/cmd/auth_groups.go @@ -41,6 +41,8 @@ func (g *groupRolesField) UnmarshalJSON(data []byte) error { if errArr := json.Unmarshal([]byte(str), &items); errArr == nil { *g = items return nil + } else { + return errArr } } diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 1d8e4a3..91b0857 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -219,4 +219,12 @@ func TestGroupRolesField_UnmarshalJSON(t *testing.T) { } else if !strings.Contains(err.Error(), "cannot unmarshal number") { t.Errorf("expected error about number unmarshaling, got: %v", err) } + + // Test stringified invalid JSON returns string unmarshal error + var g5 groupRolesField + if err := json.Unmarshal([]byte(`"not a valid json array"`), &g5); err == nil { + t.Fatalf("expected error for stringified invalid JSON, got nil") + } else if !strings.Contains(err.Error(), "invalid character") { + t.Errorf("expected invalid character error, got: %v", err) + } } diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index a0d1362..8ca018e 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -832,47 +832,55 @@ type AgentItem struct { Tools []string `json:"tools"` } -func (c *NubiClient) ListAgents(ctx context.Context) ([]AgentItem, error) { +func (c *NubiClient) listWithAccountFallback(ctx context.Context, queryName string) (json.RawMessage, error) { if ctx == nil { ctx = context.Background() } - req := client.NewRequest(` - query ListAgents($accountId: String!) { - ai_list_agents(request: {account_id: $accountId}) { + req := client.NewRequest(fmt.Sprintf(` + query ($accountId: String!) { + %s(request: {account_id: $accountId}) { data } } - `) + `, queryName)) req.Var("accountId", c.AccountID) - var respData struct { - AiListAgents struct { - Data []AgentItem `json:"data"` - } `json:"ai_list_agents"` + var respData map[string]struct { + Data json.RawMessage `json:"data"` } if err := c.Client.Run(ctx, req, &respData); err != nil { if c.AccountID != "" && strings.Contains(strings.ToLower(err.Error()), "user does not have access") { - reqFallback := client.NewRequest(` - query ListAgents { - ai_list_agents(request: {account_id: ""}) { + reqFallback := client.NewRequest(fmt.Sprintf(` + query { + %s(request: {account_id: ""}) { data } } - `) - var respDataFallback struct { - AiListAgents struct { - Data []AgentItem `json:"data"` - } `json:"ai_list_agents"` + `, queryName)) + var respDataFallback map[string]struct { + Data json.RawMessage `json:"data"` } if errFallback := c.Client.Run(ctx, reqFallback, &respDataFallback); errFallback == nil { - return respDataFallback.AiListAgents.Data, nil + return respDataFallback[queryName].Data, nil } } return nil, err } - return respData.AiListAgents.Data, nil + return respData[queryName].Data, nil +} + +func (c *NubiClient) ListAgents(ctx context.Context) ([]AgentItem, error) { + rawData, err := c.listWithAccountFallback(ctx, "ai_list_agents") + if err != nil { + return nil, err + } + var agents []AgentItem + if err := json.Unmarshal(rawData, &agents); err != nil { + return nil, fmt.Errorf("failed to unmarshal agents data: %w", err) + } + return agents, nil } type ToolItem struct { @@ -883,46 +891,15 @@ type ToolItem struct { } func (c *NubiClient) ListTools(ctx context.Context) ([]ToolItem, error) { - if ctx == nil { - ctx = context.Background() - } - req := client.NewRequest(` - query ListTools($accountId: String!) { - ai_list_tools(request: {account_id: $accountId}) { - data - } - } - `) - req.Var("accountId", c.AccountID) - - var respData struct { - AiListTools struct { - Data []ToolItem `json:"data"` - } `json:"ai_list_tools"` - } - - if err := c.Client.Run(ctx, req, &respData); err != nil { - if c.AccountID != "" && strings.Contains(strings.ToLower(err.Error()), "user does not have access") { - reqFallback := client.NewRequest(` - query ListTools { - ai_list_tools(request: {account_id: ""}) { - data - } - } - `) - var respDataFallback struct { - AiListTools struct { - Data []ToolItem `json:"data"` - } `json:"ai_list_tools"` - } - if errFallback := c.Client.Run(ctx, reqFallback, &respDataFallback); errFallback == nil { - return respDataFallback.AiListTools.Data, nil - } - } + rawData, err := c.listWithAccountFallback(ctx, "ai_list_tools") + if err != nil { return nil, err } - - return respData.AiListTools.Data, nil + var tools []ToolItem + if err := json.Unmarshal(rawData, &tools); err != nil { + return nil, fmt.Errorf("failed to unmarshal tools data: %w", err) + } + return tools, nil } type FunctionItem struct { diff --git a/pkg/nubi/nubi_test.go b/pkg/nubi/nubi_test.go index 0654f1a..2a02a60 100644 --- a/pkg/nubi/nubi_test.go +++ b/pkg/nubi/nubi_test.go @@ -317,6 +317,40 @@ func TestNubiClient_ListAgents(t *testing.T) { assert.Equal(t, "desc1", agents[0].Description) } +func TestNubiClient_ListAgents_Fallback(t *testing.T) { + callCount := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + resp := map[string]any{ + "errors": []map[string]any{ + {"message": "User does not have access"}, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + return + } + resp := map[string]any{ + "data": map[string]any{ + "ai_list_agents": map[string]any{ + "data": json.RawMessage(`[{"name":"fallback-agent","description":"fallback-desc"}]`), + }, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + } + + c, teardown := newTestNubiClient(handler) + defer teardown() + c.AccountID = "acc-restricted" + + agents, err := c.ListAgents(context.Background()) + assert.NoError(t, err) + assert.Len(t, agents, 1) + assert.Equal(t, "fallback-agent", agents[0].Name) + assert.Equal(t, 2, callCount) +} + func TestNubiClient_ListTools(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ From d39d4a6e4bf8cd36d94919ff9b0c8d2c0c90b7d6 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:50:10 +0530 Subject: [PATCH 10/16] fix(auth,nubi): handle null/empty unmarshaling, wrap fallback errors, and add comprehensive fallback tests --- cmd/auth_groups.go | 31 +++++++++++---------- cmd/auth_test.go | 9 ++++++ pkg/nubi/nubi.go | 35 ++++++++++++++++++++++- pkg/nubi/nubi_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 15 deletions(-) diff --git a/cmd/auth_groups.go b/cmd/auth_groups.go index 94712c3..a0e4f64 100644 --- a/cmd/auth_groups.go +++ b/cmd/auth_groups.go @@ -23,30 +23,33 @@ type groupRoleItem struct { type groupRolesField []groupRoleItem func (g *groupRolesField) UnmarshalJSON(data []byte) error { - if len(data) == 0 { + if len(data) == 0 || string(data) == "null" || string(data) == `""` { + *g = nil return nil } + var items []groupRoleItem - err := json.Unmarshal(data, &items) - if err == nil { + if err := json.Unmarshal(data, &items); err == nil { *g = items return nil } var str string - if errStr := json.Unmarshal(data, &str); errStr == nil { - if str == "" { - return nil - } - if errArr := json.Unmarshal([]byte(str), &items); errArr == nil { - *g = items - return nil - } else { - return errArr - } + if errStr := json.Unmarshal(data, &str); errStr != nil { + return json.Unmarshal(data, &items) + } + + if str == "" { + *g = nil + return nil + } + + if errArr := json.Unmarshal([]byte(str), &items); errArr != nil { + return errArr } - return err + *g = items + return nil } var authGroupsListCmd = &cobra.Command{ diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 91b0857..657c44b 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -227,4 +227,13 @@ func TestGroupRolesField_UnmarshalJSON(t *testing.T) { } else if !strings.Contains(err.Error(), "invalid character") { t.Errorf("expected invalid character error, got: %v", err) } + + // Test null + var g6 groupRolesField + if err := json.Unmarshal([]byte(`null`), &g6); err != nil { + t.Fatalf("unexpected error for null: %v", err) + } + if len(g6) != 0 { + t.Errorf("expected 0 items for null, got %d", len(g6)) + } } diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index 8ca018e..1c5d580 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -3,6 +3,7 @@ package nubi import ( "context" "encoding/json" + "errors" "fmt" "os" "strings" @@ -832,6 +833,36 @@ type AgentItem struct { Tools []string `json:"tools"` } +func isAccessDeniedError(err error) bool { + if err == nil { + return false + } + var gqlErrors client.GraphQLErrors + if errors.As(err, &gqlErrors) { + for _, ge := range gqlErrors { + if strings.Contains(strings.ToLower(ge.Message), "user does not have access") || + strings.Contains(strings.ToLower(ge.Message), "access-denied") || + strings.Contains(strings.ToLower(ge.Message), "unauthorized") { + return true + } + if len(ge.Extensions) > 0 { + var ext struct { + Code string `json:"code"` + } + if json.Unmarshal(ge.Extensions, &ext) == nil { + if strings.EqualFold(ext.Code, "access-denied") || strings.EqualFold(ext.Code, "unauthorized") { + return true + } + } + } + } + } + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "user does not have access") || + strings.Contains(errStr, "access-denied") || + strings.Contains(errStr, "unauthorized") +} + func (c *NubiClient) listWithAccountFallback(ctx context.Context, queryName string) (json.RawMessage, error) { if ctx == nil { ctx = context.Background() @@ -850,7 +881,7 @@ func (c *NubiClient) listWithAccountFallback(ctx context.Context, queryName stri } if err := c.Client.Run(ctx, req, &respData); err != nil { - if c.AccountID != "" && strings.Contains(strings.ToLower(err.Error()), "user does not have access") { + if c.AccountID != "" && isAccessDeniedError(err) { reqFallback := client.NewRequest(fmt.Sprintf(` query { %s(request: {account_id: ""}) { @@ -863,6 +894,8 @@ func (c *NubiClient) listWithAccountFallback(ctx context.Context, queryName stri } if errFallback := c.Client.Run(ctx, reqFallback, &respDataFallback); errFallback == nil { return respDataFallback[queryName].Data, nil + } else { + return nil, fmt.Errorf("%w (fallback error: %v)", err, errFallback) } } return nil, err diff --git a/pkg/nubi/nubi_test.go b/pkg/nubi/nubi_test.go index 2a02a60..ad0d527 100644 --- a/pkg/nubi/nubi_test.go +++ b/pkg/nubi/nubi_test.go @@ -351,6 +351,37 @@ func TestNubiClient_ListAgents_Fallback(t *testing.T) { assert.Equal(t, 2, callCount) } +func TestNubiClient_ListAgents_Fallback_BothFail(t *testing.T) { + callCount := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + resp := map[string]any{ + "errors": []map[string]any{ + {"message": "User does not have access"}, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + return + } + resp := map[string]any{ + "errors": []map[string]any{ + {"message": "server down"}, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + } + + c, teardown := newTestNubiClient(handler) + defer teardown() + c.AccountID = "acc-restricted" + + _, err := c.ListAgents(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "fallback error") + assert.Equal(t, 2, callCount) +} + func TestNubiClient_ListTools(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ @@ -373,6 +404,40 @@ func TestNubiClient_ListTools(t *testing.T) { assert.Equal(t, "desc1", tools[0].Description) } +func TestNubiClient_ListTools_Fallback(t *testing.T) { + callCount := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + resp := map[string]any{ + "errors": []map[string]any{ + {"message": "access-denied to tools"}, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + return + } + resp := map[string]any{ + "data": map[string]any{ + "ai_list_tools": map[string]any{ + "data": json.RawMessage(`[{"name":"fallback-tool","description":"fallback-tool-desc"}]`), + }, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + } + + c, teardown := newTestNubiClient(handler) + defer teardown() + c.AccountID = "acc-restricted" + + tools, err := c.ListTools(context.Background()) + assert.NoError(t, err) + assert.Len(t, tools, 1) + assert.Equal(t, "fallback-tool", tools[0].Name) + assert.Equal(t, 2, callCount) +} + func TestNubiClient_ListFunctions(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ From 75a933ea678d3b29ae76b6ea636423e464d64c18 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:53:27 +0530 Subject: [PATCH 11/16] fix(auth,nubi): avoid redundant unmarshal and guard against empty/null rawData --- cmd/auth_groups.go | 5 +++-- pkg/nubi/nubi.go | 6 ++++++ pkg/nubi/nubi_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/cmd/auth_groups.go b/cmd/auth_groups.go index a0e4f64..3086a0f 100644 --- a/cmd/auth_groups.go +++ b/cmd/auth_groups.go @@ -29,14 +29,15 @@ func (g *groupRolesField) UnmarshalJSON(data []byte) error { } var items []groupRoleItem - if err := json.Unmarshal(data, &items); err == nil { + err := json.Unmarshal(data, &items) + if err == nil { *g = items return nil } var str string if errStr := json.Unmarshal(data, &str); errStr != nil { - return json.Unmarshal(data, &items) + return err } if str == "" { diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index 1c5d580..4286f72 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -909,6 +909,9 @@ func (c *NubiClient) ListAgents(ctx context.Context) ([]AgentItem, error) { if err != nil { return nil, err } + if len(rawData) == 0 || string(rawData) == "null" { + return nil, nil + } var agents []AgentItem if err := json.Unmarshal(rawData, &agents); err != nil { return nil, fmt.Errorf("failed to unmarshal agents data: %w", err) @@ -928,6 +931,9 @@ func (c *NubiClient) ListTools(ctx context.Context) ([]ToolItem, error) { if err != nil { return nil, err } + if len(rawData) == 0 || string(rawData) == "null" { + return nil, nil + } var tools []ToolItem if err := json.Unmarshal(rawData, &tools); err != nil { return nil, fmt.Errorf("failed to unmarshal tools data: %w", err) diff --git a/pkg/nubi/nubi_test.go b/pkg/nubi/nubi_test.go index ad0d527..ddfd9b2 100644 --- a/pkg/nubi/nubi_test.go +++ b/pkg/nubi/nubi_test.go @@ -438,6 +438,44 @@ func TestNubiClient_ListTools_Fallback(t *testing.T) { assert.Equal(t, 2, callCount) } +func TestNubiClient_ListAgents_EmptyData(t *testing.T) { + handler := func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "data": map[string]any{ + "ai_list_agents": map[string]any{}, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + } + + c, teardown := newTestNubiClient(handler) + defer teardown() + + agents, err := c.ListAgents(context.Background()) + assert.NoError(t, err) + assert.Nil(t, agents) +} + +func TestNubiClient_ListTools_NullData(t *testing.T) { + handler := func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "data": map[string]any{ + "ai_list_tools": map[string]any{ + "data": nil, + }, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + } + + c, teardown := newTestNubiClient(handler) + defer teardown() + + tools, err := c.ListTools(context.Background()) + assert.NoError(t, err) + assert.Nil(t, tools) +} + func TestNubiClient_ListFunctions(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ From 0dc719ec0ab1bb67aa95d750865fa524fe34d651 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:56:36 +0530 Subject: [PATCH 12/16] fix(nubi): defensively check that response key exists and has data before returning --- pkg/nubi/nubi.go | 12 ++++++++++-- pkg/nubi/nubi_test.go | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index 4286f72..367f022 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -893,7 +893,11 @@ func (c *NubiClient) listWithAccountFallback(ctx context.Context, queryName stri Data json.RawMessage `json:"data"` } if errFallback := c.Client.Run(ctx, reqFallback, &respDataFallback); errFallback == nil { - return respDataFallback[queryName].Data, nil + res, ok := respDataFallback[queryName] + if !ok || len(res.Data) == 0 { + return nil, fmt.Errorf("fallback query %s returned no data", queryName) + } + return res.Data, nil } else { return nil, fmt.Errorf("%w (fallback error: %v)", err, errFallback) } @@ -901,7 +905,11 @@ func (c *NubiClient) listWithAccountFallback(ctx context.Context, queryName stri return nil, err } - return respData[queryName].Data, nil + res, ok := respData[queryName] + if !ok || len(res.Data) == 0 { + return nil, fmt.Errorf("query %s returned no data", queryName) + } + return res.Data, nil } func (c *NubiClient) ListAgents(ctx context.Context) ([]AgentItem, error) { diff --git a/pkg/nubi/nubi_test.go b/pkg/nubi/nubi_test.go index ddfd9b2..4e80ed2 100644 --- a/pkg/nubi/nubi_test.go +++ b/pkg/nubi/nubi_test.go @@ -452,7 +452,8 @@ func TestNubiClient_ListAgents_EmptyData(t *testing.T) { defer teardown() agents, err := c.ListAgents(context.Background()) - assert.NoError(t, err) + assert.Error(t, err) + assert.Contains(t, err.Error(), "returned no data") assert.Nil(t, agents) } From b51033413225842dd98fd78777e4e1320a93ca18 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 19:59:43 +0530 Subject: [PATCH 13/16] fix(auth,nubi): always pass permissions variable and wrap fallback error with %w --- cmd/auth_roles.go | 4 +--- pkg/nubi/nubi.go | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/auth_roles.go b/cmd/auth_roles.go index bf389b3..6ea3197 100644 --- a/cmd/auth_roles.go +++ b/cmd/auth_roles.go @@ -156,9 +156,7 @@ var authRolesCreateCmd = &cobra.Command{ if desc != "" { req.Var("description", desc) } - if len(permInputs) > 0 { - req.Var("permissions", permInputs) - } + req.Var("permissions", permInputs) var respData struct { CustomrolesCreate struct { diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index 367f022..aa3b606 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -899,7 +899,7 @@ func (c *NubiClient) listWithAccountFallback(ctx context.Context, queryName stri } return res.Data, nil } else { - return nil, fmt.Errorf("%w (fallback error: %v)", err, errFallback) + return nil, fmt.Errorf("%w (fallback error: %w)", err, errFallback) } } return nil, err From f34fd84a0b02f7e7254d15e6b97a492123eac378 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 20:03:25 +0530 Subject: [PATCH 14/16] fix(auth,nubi): allowlist query names, pass nil description variable, and trim whitespace in unmarshaler --- cmd/auth_groups.go | 11 +++++++---- cmd/auth_roles.go | 2 ++ cmd/auth_test.go | 13 +++++++++++-- pkg/nubi/nubi.go | 7 +++++++ pkg/nubi/nubi_test.go | 7 +++++++ 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/cmd/auth_groups.go b/cmd/auth_groups.go index 3086a0f..6a9f455 100644 --- a/cmd/auth_groups.go +++ b/cmd/auth_groups.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "encoding/json" "strings" @@ -23,24 +24,26 @@ type groupRoleItem struct { type groupRolesField []groupRoleItem func (g *groupRolesField) UnmarshalJSON(data []byte) error { - if len(data) == 0 || string(data) == "null" || string(data) == `""` { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || string(trimmed) == "null" || string(trimmed) == `""` { *g = nil return nil } var items []groupRoleItem - err := json.Unmarshal(data, &items) + err := json.Unmarshal(trimmed, &items) if err == nil { *g = items return nil } var str string - if errStr := json.Unmarshal(data, &str); errStr != nil { + if errStr := json.Unmarshal(trimmed, &str); errStr != nil { return err } - if str == "" { + str = strings.TrimSpace(str) + if str == "" || str == "null" { *g = nil return nil } diff --git a/cmd/auth_roles.go b/cmd/auth_roles.go index 6ea3197..c2e3825 100644 --- a/cmd/auth_roles.go +++ b/cmd/auth_roles.go @@ -155,6 +155,8 @@ var authRolesCreateCmd = &cobra.Command{ req.Var("name", roleName) if desc != "" { req.Var("description", desc) + } else { + req.Var("description", nil) } req.Var("permissions", permInputs) diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 657c44b..78e8b84 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -230,10 +230,19 @@ func TestGroupRolesField_UnmarshalJSON(t *testing.T) { // Test null var g6 groupRolesField - if err := json.Unmarshal([]byte(`null`), &g6); err != nil { - t.Fatalf("unexpected error for null: %v", err) + if err := json.Unmarshal([]byte(` null `), &g6); err != nil { + t.Fatalf("unexpected error for null with whitespace: %v", err) } if len(g6) != 0 { t.Errorf("expected 0 items for null, got %d", len(g6)) } + + // Test string with whitespace + var g7 groupRolesField + if err := json.Unmarshal([]byte(` " " `), &g7); err != nil { + t.Fatalf("unexpected error for empty string with whitespace: %v", err) + } + if len(g7) != 0 { + t.Errorf("expected 0 items for empty string with whitespace, got %d", len(g7)) + } } diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index aa3b606..b9800c9 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -864,6 +864,13 @@ func isAccessDeniedError(err error) bool { } func (c *NubiClient) listWithAccountFallback(ctx context.Context, queryName string) (json.RawMessage, error) { + switch queryName { + case "ai_list_agents", "ai_list_tools": + // Allowed query names + default: + return nil, fmt.Errorf("invalid or disallowed query name: %q", queryName) + } + if ctx == nil { ctx = context.Background() } diff --git a/pkg/nubi/nubi_test.go b/pkg/nubi/nubi_test.go index 4e80ed2..5f8b22d 100644 --- a/pkg/nubi/nubi_test.go +++ b/pkg/nubi/nubi_test.go @@ -477,6 +477,13 @@ func TestNubiClient_ListTools_NullData(t *testing.T) { assert.Nil(t, tools) } +func TestNubiClient_ListWithAccountFallback_DisallowedQuery(t *testing.T) { + c := &NubiClient{} + _, err := c.listWithAccountFallback(context.Background(), "malicious_query") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid or disallowed query name") +} + func TestNubiClient_ListFunctions(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ From 0983f1faca2324adf22c14ab0cae0b50d6e9d8c2 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 20:07:47 +0530 Subject: [PATCH 15/16] fix(nubi): format fallback error with %v instead of multiple %w --- pkg/nubi/nubi.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/nubi/nubi.go b/pkg/nubi/nubi.go index b9800c9..cd8055e 100644 --- a/pkg/nubi/nubi.go +++ b/pkg/nubi/nubi.go @@ -906,7 +906,7 @@ func (c *NubiClient) listWithAccountFallback(ctx context.Context, queryName stri } return res.Data, nil } else { - return nil, fmt.Errorf("%w (fallback error: %w)", err, errFallback) + return nil, fmt.Errorf("%w (fallback error: %v)", err, errFallback) } } return nil, err From 7166f3d2de40908a2e81f8f8df0dba0ae8682ede Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 20:11:18 +0530 Subject: [PATCH 16/16] fix(auth): explicitly pass nil description variable in authGroupsCreateCmd --- cmd/auth_groups.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/auth_groups.go b/cmd/auth_groups.go index 6a9f455..21d455f 100644 --- a/cmd/auth_groups.go +++ b/cmd/auth_groups.go @@ -162,6 +162,8 @@ var authGroupsCreateCmd = &cobra.Command{ req.Var("name", groupName) if desc != "" { req.Var("description", desc) + } else { + req.Var("description", nil) } var respData struct {