Skip to content

Commit cd528d3

Browse files
Add scope validation feature to inventory Builder
Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com>
1 parent 5ac1eb4 commit cd528d3

File tree

2 files changed

+235
-0
lines changed

2 files changed

+235
-0
lines changed

pkg/inventory/builder.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package inventory
22

33
import (
44
"context"
5+
"fmt"
56
"sort"
67
"strings"
78
)
@@ -25,6 +26,7 @@ type ToolFilter func(ctx context.Context, tool *ServerTool) (bool, error)
2526
// WithToolsets([]string{"repos", "issues"}).
2627
// WithFeatureChecker(checker).
2728
// WithFilter(myFilter).
29+
// WithRequireScopes(true).
2830
// Build()
2931
type Builder struct {
3032
tools []ServerTool
@@ -39,6 +41,7 @@ type Builder struct {
3941
additionalTools []string // raw input, processed at Build()
4042
featureChecker FeatureFlagChecker
4143
filters []ToolFilter // filters to apply to all tools
44+
requireScopes bool // when true, validates all tools have scope definitions
4245
}
4346

4447
// NewBuilder creates a new Builder.
@@ -127,11 +130,34 @@ func (b *Builder) WithFilter(filter ToolFilter) *Builder {
127130
return b
128131
}
129132

133+
// WithRequireScopes enables validation that all tools have scope definitions.
134+
// When enabled, Build() will panic if any tool has both RequiredScopes and
135+
// AcceptedScopes set to nil. Empty slices ([]string{}) are allowed, as they
136+
// explicitly indicate the tool requires no scopes.
137+
// Returns self for chaining.
138+
func (b *Builder) WithRequireScopes(require bool) *Builder {
139+
b.requireScopes = require
140+
return b
141+
}
142+
130143
// Build creates the final Inventory with all configuration applied.
131144
// This processes toolset filtering, tool name resolution, and sets up
132145
// the inventory for use. The returned Inventory is ready for use with
133146
// AvailableTools(), RegisterAll(), etc.
147+
//
148+
// If WithRequireScopes(true) was called, this method will panic if any
149+
// tool has both RequiredScopes and AcceptedScopes set to nil.
134150
func (b *Builder) Build() *Inventory {
151+
// Validate scopes if required
152+
if b.requireScopes {
153+
for i := range b.tools {
154+
tool := &b.tools[i]
155+
if tool.RequiredScopes == nil && tool.AcceptedScopes == nil {
156+
panic(fmt.Sprintf("tool %q missing scope definitions (both RequiredScopes and AcceptedScopes are nil)", tool.Tool.Name))
157+
}
158+
}
159+
}
160+
135161
r := &Inventory{
136162
tools: b.tools,
137163
resourceTemplates: b.resourceTemplates,

pkg/inventory/builder_test.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package inventory
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// mockToolWithScopes creates a mock tool with specified scope definitions
8+
func mockToolWithScopes(name string, toolsetID string, requiredScopes, acceptedScopes []string) ServerTool {
9+
tool := mockTool(name, toolsetID, false)
10+
tool.RequiredScopes = requiredScopes
11+
tool.AcceptedScopes = acceptedScopes
12+
return tool
13+
}
14+
15+
func TestWithRequireScopes_AllToolsHaveScopes(t *testing.T) {
16+
tools := []ServerTool{
17+
mockToolWithScopes("tool1", "toolset1", []string{"repo"}, []string{"repo", "admin:org"}),
18+
mockToolWithScopes("tool2", "toolset1", []string{"repo", "user"}, []string{"repo", "user", "admin:org"}),
19+
mockToolWithScopes("tool3", "toolset2", []string{}, []string{}), // empty slices are valid
20+
}
21+
22+
// Should not panic when all tools have scope definitions
23+
defer func() {
24+
if r := recover(); r != nil {
25+
t.Fatalf("Build() panicked unexpectedly: %v", r)
26+
}
27+
}()
28+
29+
_ = NewBuilder().
30+
SetTools(tools).
31+
WithRequireScopes(true).
32+
Build()
33+
}
34+
35+
func TestWithRequireScopes_ToolMissingBothScopes(t *testing.T) {
36+
tools := []ServerTool{
37+
mockToolWithScopes("tool1", "toolset1", []string{"repo"}, []string{"repo"}),
38+
mockTool("tool2", "toolset1", false), // This tool has nil RequiredScopes and AcceptedScopes
39+
}
40+
41+
// Should panic when a tool is missing both scope definitions
42+
defer func() {
43+
r := recover()
44+
if r == nil {
45+
t.Fatal("Build() should have panicked for tool missing scope definitions")
46+
}
47+
errMsg, ok := r.(string)
48+
if !ok {
49+
t.Fatalf("Expected panic message to be a string, got %T", r)
50+
}
51+
expectedMsg := `tool "tool2" missing scope definitions (both RequiredScopes and AcceptedScopes are nil)`
52+
if errMsg != expectedMsg {
53+
t.Errorf("Expected panic message %q, got %q", expectedMsg, errMsg)
54+
}
55+
}()
56+
57+
_ = NewBuilder().
58+
SetTools(tools).
59+
WithRequireScopes(true).
60+
Build()
61+
}
62+
63+
func TestWithRequireScopes_OnlyRequiredScopesSet(t *testing.T) {
64+
tool := mockTool("tool1", "toolset1", false)
65+
tool.RequiredScopes = []string{"repo"}
66+
tool.AcceptedScopes = nil
67+
68+
tools := []ServerTool{tool}
69+
70+
// Should not panic when only RequiredScopes is set (AcceptedScopes can be nil if RequiredScopes is set)
71+
defer func() {
72+
if r := recover(); r != nil {
73+
t.Fatalf("Build() panicked unexpectedly: %v", r)
74+
}
75+
}()
76+
77+
_ = NewBuilder().
78+
SetTools(tools).
79+
WithRequireScopes(true).
80+
Build()
81+
}
82+
83+
func TestWithRequireScopes_OnlyAcceptedScopesSet(t *testing.T) {
84+
tool := mockTool("tool1", "toolset1", false)
85+
tool.RequiredScopes = nil
86+
tool.AcceptedScopes = []string{"repo"}
87+
88+
tools := []ServerTool{tool}
89+
90+
// Should not panic when only AcceptedScopes is set (RequiredScopes can be nil if AcceptedScopes is set)
91+
defer func() {
92+
if r := recover(); r != nil {
93+
t.Fatalf("Build() panicked unexpectedly: %v", r)
94+
}
95+
}()
96+
97+
_ = NewBuilder().
98+
SetTools(tools).
99+
WithRequireScopes(true).
100+
Build()
101+
}
102+
103+
func TestWithRequireScopes_EmptySlicesAllowed(t *testing.T) {
104+
tools := []ServerTool{
105+
mockToolWithScopes("tool1", "toolset1", []string{}, []string{}),
106+
mockToolWithScopes("tool2", "toolset2", []string{}, []string{}),
107+
}
108+
109+
// Should not panic when tools have empty slices (explicit "no scopes needed")
110+
defer func() {
111+
if r := recover(); r != nil {
112+
t.Fatalf("Build() panicked unexpectedly: %v", r)
113+
}
114+
}()
115+
116+
_ = NewBuilder().
117+
SetTools(tools).
118+
WithRequireScopes(true).
119+
Build()
120+
}
121+
122+
func TestWithRequireScopes_False(t *testing.T) {
123+
tools := []ServerTool{
124+
mockTool("tool1", "toolset1", false), // Missing scope definitions
125+
mockTool("tool2", "toolset1", false), // Missing scope definitions
126+
}
127+
128+
// Should not panic when WithRequireScopes(false) or not set
129+
defer func() {
130+
if r := recover(); r != nil {
131+
t.Fatalf("Build() panicked unexpectedly: %v", r)
132+
}
133+
}()
134+
135+
_ = NewBuilder().
136+
SetTools(tools).
137+
WithRequireScopes(false).
138+
Build()
139+
}
140+
141+
func TestWithRequireScopes_NotSet(t *testing.T) {
142+
tools := []ServerTool{
143+
mockTool("tool1", "toolset1", false), // Missing scope definitions
144+
mockTool("tool2", "toolset1", false), // Missing scope definitions
145+
}
146+
147+
// Should not panic when WithRequireScopes is not called (default is false)
148+
defer func() {
149+
if r := recover(); r != nil {
150+
t.Fatalf("Build() panicked unexpectedly: %v", r)
151+
}
152+
}()
153+
154+
_ = NewBuilder().
155+
SetTools(tools).
156+
Build()
157+
}
158+
159+
func TestWithRequireScopes_MixedTools(t *testing.T) {
160+
tools := []ServerTool{
161+
mockToolWithScopes("tool1", "toolset1", []string{"repo"}, []string{"repo"}),
162+
mockToolWithScopes("tool2", "toolset1", []string{}, []string{}),
163+
mockTool("tool3", "toolset2", false), // Missing scope definitions
164+
}
165+
166+
// Should panic on the first tool with missing scope definitions
167+
defer func() {
168+
r := recover()
169+
if r == nil {
170+
t.Fatal("Build() should have panicked for tool missing scope definitions")
171+
}
172+
errMsg, ok := r.(string)
173+
if !ok {
174+
t.Fatalf("Expected panic message to be a string, got %T", r)
175+
}
176+
expectedMsg := `tool "tool3" missing scope definitions (both RequiredScopes and AcceptedScopes are nil)`
177+
if errMsg != expectedMsg {
178+
t.Errorf("Expected panic message %q, got %q", expectedMsg, errMsg)
179+
}
180+
}()
181+
182+
_ = NewBuilder().
183+
SetTools(tools).
184+
WithRequireScopes(true).
185+
Build()
186+
}
187+
188+
func TestWithRequireScopes_Chaining(t *testing.T) {
189+
tools := []ServerTool{
190+
mockToolWithScopes("tool1", "toolset1", []string{"repo"}, []string{"repo"}),
191+
}
192+
193+
// Test that WithRequireScopes returns the builder for chaining
194+
builder := NewBuilder()
195+
result := builder.WithRequireScopes(true)
196+
197+
if result != builder {
198+
t.Error("WithRequireScopes should return the same builder instance for chaining")
199+
}
200+
201+
// Verify the build works
202+
defer func() {
203+
if r := recover(); r != nil {
204+
t.Fatalf("Build() panicked unexpectedly: %v", r)
205+
}
206+
}()
207+
208+
_ = result.SetTools(tools).Build()
209+
}

0 commit comments

Comments
 (0)