Commit f1dc8fc

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