repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
f1dc8fc
parent
5076bb7
author
Eric Bower
date
2025-12-26 10:46:57 -0500 EST
chore(pipe): extract topic name logic into testable function
2 files changed,  +371, -0
M pkg/apps/pipe/cli.go
+57, -0
 1@@ -759,6 +759,63 @@ func toPublicTopic(topic string) string {
 2 	return fmt.Sprintf("public/%s", topic)
 3 }
 4 
 5+// TopicResolveInput contains all inputs needed for topic resolution.
 6+type TopicResolveInput struct {
 7+	UserName           string
 8+	Topic              string
 9+	IsAdmin            bool
10+	IsPublic           bool
11+	AccessList         []string
12+	ExistingAccessList []string
13+	HasExistingAccess  bool
14+	IsAccessCreator    bool
15+	HasUserAccess      bool
16+}
17+
18+// TopicResolveOutput contains the resolved topic name and any error.
19+type TopicResolveOutput struct {
20+	Name             string
21+	WithoutUser      string
22+	AccessDenied     bool
23+	GenerateNewTopic bool
24+}
25+
26+// resolveTopic determines the final topic name based on user, flags, and access control.
27+func resolveTopic(input TopicResolveInput) TopicResolveOutput {
28+	var name string
29+	var withoutUser string
30+
31+	if input.IsAdmin && strings.HasPrefix(input.Topic, "/") {
32+		name = strings.TrimPrefix(input.Topic, "/")
33+		return TopicResolveOutput{Name: name, WithoutUser: withoutUser}
34+	}
35+
36+	name = toTopic(input.UserName, input.Topic)
37+	if input.IsPublic {
38+		name = toPublicTopic(input.Topic)
39+		withoutUser = name
40+	} else {
41+		withoutUser = input.Topic
42+	}
43+
44+	if input.HasExistingAccess && len(input.ExistingAccessList) > 0 && !input.IsAdmin {
45+		if input.HasUserAccess || input.IsAccessCreator {
46+			name = withoutUser
47+		} else if !input.IsPublic {
48+			name = toTopic(input.UserName, withoutUser)
49+		} else {
50+			return TopicResolveOutput{
51+				Name:             name,
52+				WithoutUser:      withoutUser,
53+				AccessDenied:     true,
54+				GenerateNewTopic: true,
55+			}
56+		}
57+	}
58+
59+	return TopicResolveOutput{Name: name, WithoutUser: withoutUser}
60+}
61+
62 func clientInfo(clients []*psub.Client, isAdmin bool, clientType string) string {
63 	if len(clients) == 0 {
64 		return ""
A pkg/apps/pipe/topic_test.go
+314, -0
  1@@ -0,0 +1,314 @@
  2+package pipe
  3+
  4+import (
  5+	"testing"
  6+)
  7+
  8+func TestToTopic(t *testing.T) {
  9+	tests := []struct {
 10+		name     string
 11+		userName string
 12+		topic    string
 13+		want     string
 14+	}{
 15+		{
 16+			name:     "basic topic",
 17+			userName: "alice",
 18+			topic:    "mytopic",
 19+			want:     "alice/mytopic",
 20+		},
 21+		{
 22+			name:     "already prefixed with username",
 23+			userName: "alice",
 24+			topic:    "alice/mytopic",
 25+			want:     "alice/mytopic",
 26+		},
 27+		{
 28+			name:     "different user prefix not stripped",
 29+			userName: "alice",
 30+			topic:    "bob/mytopic",
 31+			want:     "alice/bob/mytopic",
 32+		},
 33+		{
 34+			name:     "empty topic",
 35+			userName: "alice",
 36+			topic:    "",
 37+			want:     "alice/",
 38+		},
 39+	}
 40+
 41+	for _, tt := range tests {
 42+		t.Run(tt.name, func(t *testing.T) {
 43+			got := toTopic(tt.userName, tt.topic)
 44+			if got != tt.want {
 45+				t.Errorf("toTopic(%q, %q) = %q, want %q", tt.userName, tt.topic, got, tt.want)
 46+			}
 47+		})
 48+	}
 49+}
 50+
 51+func TestToPublicTopic(t *testing.T) {
 52+	tests := []struct {
 53+		name  string
 54+		topic string
 55+		want  string
 56+	}{
 57+		{
 58+			name:  "basic topic",
 59+			topic: "mytopic",
 60+			want:  "public/mytopic",
 61+		},
 62+		{
 63+			name:  "already public prefixed",
 64+			topic: "public/mytopic",
 65+			want:  "public/mytopic",
 66+		},
 67+		{
 68+			name:  "empty topic",
 69+			topic: "",
 70+			want:  "public/",
 71+		},
 72+	}
 73+
 74+	for _, tt := range tests {
 75+		t.Run(tt.name, func(t *testing.T) {
 76+			got := toPublicTopic(tt.topic)
 77+			if got != tt.want {
 78+				t.Errorf("toPublicTopic(%q) = %q, want %q", tt.topic, got, tt.want)
 79+			}
 80+		})
 81+	}
 82+}
 83+
 84+func TestParseArgList(t *testing.T) {
 85+	tests := []struct {
 86+		name string
 87+		arg  string
 88+		want []string
 89+	}{
 90+		{
 91+			name: "single item",
 92+			arg:  "alice",
 93+			want: []string{"alice"},
 94+		},
 95+		{
 96+			name: "multiple items",
 97+			arg:  "alice,bob,charlie",
 98+			want: []string{"alice", "bob", "charlie"},
 99+		},
100+		{
101+			name: "items with spaces",
102+			arg:  "alice, bob , charlie",
103+			want: []string{"alice", "bob", "charlie"},
104+		},
105+		{
106+			name: "empty string",
107+			arg:  "",
108+			want: []string{""},
109+		},
110+	}
111+
112+	for _, tt := range tests {
113+		t.Run(tt.name, func(t *testing.T) {
114+			got := parseArgList(tt.arg)
115+			if len(got) != len(tt.want) {
116+				t.Errorf("parseArgList(%q) returned %d items, want %d", tt.arg, len(got), len(tt.want))
117+				return
118+			}
119+			for i := range got {
120+				if got[i] != tt.want[i] {
121+					t.Errorf("parseArgList(%q)[%d] = %q, want %q", tt.arg, i, got[i], tt.want[i])
122+				}
123+			}
124+		})
125+	}
126+}
127+
128+func TestResolveTopic(t *testing.T) {
129+	tests := []struct {
130+		name   string
131+		input  TopicResolveInput
132+		expect TopicResolveOutput
133+	}{
134+		{
135+			name: "basic private topic",
136+			input: TopicResolveInput{
137+				UserName: "alice",
138+				Topic:    "mytopic",
139+				IsAdmin:  false,
140+				IsPublic: false,
141+			},
142+			expect: TopicResolveOutput{
143+				Name:        "alice/mytopic",
144+				WithoutUser: "mytopic",
145+			},
146+		},
147+		{
148+			name: "public topic",
149+			input: TopicResolveInput{
150+				UserName: "alice",
151+				Topic:    "mytopic",
152+				IsAdmin:  false,
153+				IsPublic: true,
154+			},
155+			expect: TopicResolveOutput{
156+				Name:        "public/mytopic",
157+				WithoutUser: "public/mytopic",
158+			},
159+		},
160+		{
161+			name: "admin with absolute path",
162+			input: TopicResolveInput{
163+				UserName: "admin",
164+				Topic:    "/rawtopic",
165+				IsAdmin:  true,
166+				IsPublic: false,
167+			},
168+			expect: TopicResolveOutput{
169+				Name:        "rawtopic",
170+				WithoutUser: "",
171+			},
172+		},
173+		{
174+			name: "admin without absolute path treated as regular user",
175+			input: TopicResolveInput{
176+				UserName: "admin",
177+				Topic:    "mytopic",
178+				IsAdmin:  true,
179+				IsPublic: false,
180+			},
181+			expect: TopicResolveOutput{
182+				Name:        "admin/mytopic",
183+				WithoutUser: "mytopic",
184+			},
185+		},
186+		{
187+			name: "topic already prefixed with username",
188+			input: TopicResolveInput{
189+				UserName: "alice",
190+				Topic:    "alice/mytopic",
191+				IsAdmin:  false,
192+				IsPublic: false,
193+			},
194+			expect: TopicResolveOutput{
195+				Name:        "alice/mytopic",
196+				WithoutUser: "alice/mytopic",
197+			},
198+		},
199+		{
200+			name: "public topic already prefixed",
201+			input: TopicResolveInput{
202+				UserName: "alice",
203+				Topic:    "public/mytopic",
204+				IsAdmin:  false,
205+				IsPublic: true,
206+			},
207+			expect: TopicResolveOutput{
208+				Name:        "public/mytopic",
209+				WithoutUser: "public/mytopic",
210+			},
211+		},
212+		{
213+			name: "access list exists - user has access",
214+			input: TopicResolveInput{
215+				UserName:           "bob",
216+				Topic:              "sharedtopic",
217+				IsAdmin:            false,
218+				IsPublic:           false,
219+				ExistingAccessList: []string{"bob", "charlie"},
220+				HasExistingAccess:  true,
221+				HasUserAccess:      true,
222+			},
223+			expect: TopicResolveOutput{
224+				Name:        "sharedtopic",
225+				WithoutUser: "sharedtopic",
226+			},
227+		},
228+		{
229+			name: "access list exists - user denied (private)",
230+			input: TopicResolveInput{
231+				UserName:           "eve",
232+				Topic:              "sharedtopic",
233+				IsAdmin:            false,
234+				IsPublic:           false,
235+				ExistingAccessList: []string{"bob", "charlie"},
236+				HasExistingAccess:  true,
237+				HasUserAccess:      false,
238+			},
239+			expect: TopicResolveOutput{
240+				Name:        "eve/sharedtopic",
241+				WithoutUser: "sharedtopic",
242+			},
243+		},
244+		{
245+			name: "access list exists - user denied (public) - generates new topic",
246+			input: TopicResolveInput{
247+				UserName:           "eve",
248+				Topic:              "sharedtopic",
249+				IsAdmin:            false,
250+				IsPublic:           true,
251+				ExistingAccessList: []string{"bob", "charlie"},
252+				HasExistingAccess:  true,
253+				HasUserAccess:      false,
254+			},
255+			expect: TopicResolveOutput{
256+				Name:             "public/sharedtopic",
257+				WithoutUser:      "public/sharedtopic",
258+				AccessDenied:     true,
259+				GenerateNewTopic: true,
260+			},
261+		},
262+		{
263+			name: "access list creator gets access",
264+			input: TopicResolveInput{
265+				UserName:           "alice",
266+				Topic:              "newtopic",
267+				IsAdmin:            false,
268+				IsPublic:           false,
269+				AccessList:         []string{"bob"},
270+				ExistingAccessList: []string{"bob"},
271+				HasExistingAccess:  true,
272+				IsAccessCreator:    true,
273+				HasUserAccess:      false,
274+			},
275+			expect: TopicResolveOutput{
276+				Name:        "newtopic",
277+				WithoutUser: "newtopic",
278+			},
279+		},
280+		{
281+			name: "admin bypasses access control",
282+			input: TopicResolveInput{
283+				UserName:           "admin",
284+				Topic:              "restricted",
285+				IsAdmin:            true,
286+				IsPublic:           false,
287+				ExistingAccessList: []string{"bob"},
288+				HasExistingAccess:  true,
289+				HasUserAccess:      false,
290+			},
291+			expect: TopicResolveOutput{
292+				Name:        "admin/restricted",
293+				WithoutUser: "restricted",
294+			},
295+		},
296+	}
297+
298+	for _, tt := range tests {
299+		t.Run(tt.name, func(t *testing.T) {
300+			got := resolveTopic(tt.input)
301+			if got.Name != tt.expect.Name {
302+				t.Errorf("resolveTopic().Name = %q, want %q", got.Name, tt.expect.Name)
303+			}
304+			if got.WithoutUser != tt.expect.WithoutUser {
305+				t.Errorf("resolveTopic().WithoutUser = %q, want %q", got.WithoutUser, tt.expect.WithoutUser)
306+			}
307+			if got.AccessDenied != tt.expect.AccessDenied {
308+				t.Errorf("resolveTopic().AccessDenied = %v, want %v", got.AccessDenied, tt.expect.AccessDenied)
309+			}
310+			if got.GenerateNewTopic != tt.expect.GenerateNewTopic {
311+				t.Errorf("resolveTopic().GenerateNewTopic = %v, want %v", got.GenerateNewTopic, tt.expect.GenerateNewTopic)
312+			}
313+		})
314+	}
315+}