- 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
+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 ""
+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+}