Skip to content

Commit 500c820

Browse files
authored
Merge pull request #288 from ninech/bucket-crud
feat: add CRUD commands for bucket resources.
2 parents 0131ce3 + c53cac5 commit 500c820

File tree

19 files changed

+3243
-4
lines changed

19 files changed

+3243
-4
lines changed

api/util/bucket.go

Lines changed: 835 additions & 0 deletions
Large diffs are not rendered by default.

api/util/bucket_test.go

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
package util
2+
3+
import (
4+
"testing"
5+
6+
storage "github.com/ninech/apis/storage/v1alpha1"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestParseSegments(t *testing.T) {
12+
t.Parallel()
13+
14+
tests := []struct {
15+
name string
16+
chunks []string
17+
wantPairs []kvPair
18+
wantErr string
19+
}{
20+
{
21+
name: "simple input",
22+
chunks: []string{"key=value"},
23+
wantPairs: []kvPair{{key: "key", val: "value", segmentIndex: 1, raw: "key=value"}},
24+
},
25+
{
26+
name: "empty input",
27+
chunks: nil,
28+
wantPairs: []kvPair{},
29+
},
30+
{
31+
name: "skips empty segments",
32+
chunks: []string{" ", "", "key=value", " "}, // removed " ; "
33+
wantPairs: []kvPair{{key: "key", val: "value", segmentIndex: 3, raw: "key=value"}},
34+
},
35+
{
36+
name: "errors on key only",
37+
chunks: []string{"keyonly"},
38+
wantErr: `segment 1 "keyonly" must be key=value`,
39+
},
40+
{
41+
// TODO: not sure I should allow this:
42+
name: "empty value allowed",
43+
chunks: []string{"keyonly="},
44+
wantPairs: []kvPair{{key: "keyonly", val: "", segmentIndex: 1, raw: "keyonly="}},
45+
},
46+
{
47+
name: "errors on empty key",
48+
chunks: []string{" =value "},
49+
wantErr: "segment 1 has empty key",
50+
},
51+
{
52+
name: "trims spaces around key and value",
53+
chunks: []string{" k = v "},
54+
wantPairs: []kvPair{{key: "k", val: "v", segmentIndex: 1, raw: " k = v "}},
55+
},
56+
{
57+
name: "multiple valid pairs",
58+
chunks: []string{"a=1", "b=2"},
59+
wantPairs: []kvPair{{key: "a", val: "1", segmentIndex: 1, raw: "a=1"}, {key: "b", val: "2", segmentIndex: 2, raw: "b=2"}},
60+
},
61+
}
62+
63+
for _, tt := range tests {
64+
t.Run(tt.name, func(t *testing.T) {
65+
got, err := parseSegmentsStrict(tt.chunks)
66+
if tt.wantErr != "" {
67+
assert.Error(t, err)
68+
assert.Contains(t, err.Error(), tt.wantErr)
69+
return
70+
}
71+
assert.NoError(t, err)
72+
assert.Equal(t, tt.wantPairs, got)
73+
})
74+
}
75+
}
76+
77+
func TestParsePermissions(t *testing.T) {
78+
t.Parallel()
79+
80+
tests := []struct {
81+
name string
82+
chunks []string
83+
allowEmptyUsers bool
84+
want []PermissionSpec
85+
wantErr string
86+
}{
87+
{
88+
name: "ok: single role with multiple users",
89+
chunks: []string{
90+
"reader=test-user-1,guest-user-2",
91+
},
92+
allowEmptyUsers: false,
93+
want: []PermissionSpec{
94+
{Role: "reader", Users: []string{"guest-user-2", "test-user-1"}},
95+
},
96+
},
97+
{
98+
name: "ok: multiple roles, merged & deduped users",
99+
chunks: []string{
100+
"reader=test-user-1,test-user-1",
101+
"writer=super-user-1",
102+
"reader=guest-user-2",
103+
},
104+
allowEmptyUsers: false,
105+
want: []PermissionSpec{
106+
{Role: "reader", Users: []string{"guest-user-2", "test-user-1"}},
107+
{Role: "writer", Users: []string{"super-user-1"}},
108+
},
109+
},
110+
{
111+
name: "error: missing users when not allowed",
112+
chunks: []string{
113+
"reader=",
114+
},
115+
allowEmptyUsers: false,
116+
wantErr: "no users",
117+
},
118+
{
119+
name: "ok: explicit role-only is allowed for deletes",
120+
chunks: []string{
121+
"writer=",
122+
},
123+
allowEmptyUsers: true,
124+
// Users should be omitted (nil) to indicate role-only spec
125+
want: []PermissionSpec{
126+
{Role: "writer", Users: nil},
127+
},
128+
},
129+
{
130+
name: "error: malformed segment",
131+
chunks: []string{
132+
"reader test-user-1",
133+
},
134+
allowEmptyUsers: false,
135+
wantErr: "must be key=value",
136+
},
137+
{
138+
name: "ok: trims empties in CSV; still errors if nothing left when not allowed",
139+
chunks: []string{
140+
"reader= , ,",
141+
},
142+
allowEmptyUsers: false,
143+
wantErr: "no users",
144+
},
145+
{
146+
name: "ok: trims empties in CSV; allowed when allowEmptyUsers=true",
147+
chunks: []string{
148+
"reader= , ,",
149+
},
150+
allowEmptyUsers: true,
151+
want: []PermissionSpec{
152+
{Role: "reader", Users: nil},
153+
},
154+
},
155+
}
156+
157+
for _, tt := range tests {
158+
t.Run(tt.name, func(t *testing.T) {
159+
got, err := parsePermissions(tt.chunks, tt.allowEmptyUsers)
160+
if tt.wantErr != "" {
161+
assert.Error(t, err)
162+
assert.Contains(t, err.Error(), tt.wantErr)
163+
return
164+
}
165+
assert.NoError(t, err)
166+
assert.Equal(t, tt.want, got)
167+
})
168+
}
169+
}
170+
171+
func TestParseCORSLooseWithMask(t *testing.T) {
172+
t.Parallel()
173+
174+
tests := []struct {
175+
name string
176+
chunks []string
177+
want storage.CORSConfig
178+
wantMask CORSFieldMask
179+
wantErr string
180+
nilFields struct {
181+
origins bool
182+
responseHeaders bool
183+
}
184+
}{
185+
{
186+
name: "ok: single flag with all keys",
187+
chunks: []string{
188+
"origins=https://example.com, https://app.example.com ",
189+
"response-headers= X-My-Header ,ETag ",
190+
"max-age=3600",
191+
},
192+
want: storage.CORSConfig{
193+
Origins: []string{"https://app.example.com", "https://example.com"},
194+
ResponseHeaders: []string{"ETag", "X-My-Header"},
195+
MaxAge: 3600,
196+
},
197+
wantMask: CORSFieldMask{Origins: true, ResponseHeaders: true, MaxAge: true},
198+
},
199+
{
200+
name: "ok: multiple flags merged & deduped; max-age not provided -> stays zero (unset here, CRD will default later)",
201+
chunks: []string{
202+
"origins=https://example.com,https://example.com",
203+
"origins=https://app.example.com",
204+
"response-headers=ETag",
205+
"response-headers=X-My-Header",
206+
},
207+
want: storage.CORSConfig{
208+
Origins: []string{"https://app.example.com", "https://example.com"},
209+
ResponseHeaders: []string{"ETag", "X-My-Header"},
210+
MaxAge: 0, // parser leaves unset
211+
},
212+
wantMask: CORSFieldMask{Origins: true, ResponseHeaders: true},
213+
},
214+
{
215+
name: "ok: empty response-headers allowed (treated as none), max-age not provided",
216+
chunks: []string{
217+
"origins=https://example.com",
218+
"response-headers=",
219+
},
220+
want: storage.CORSConfig{
221+
Origins: []string{"https://example.com"},
222+
MaxAge: 0,
223+
},
224+
wantMask: CORSFieldMask{Origins: true, ResponseHeaders: true},
225+
nilFields: struct {
226+
origins, responseHeaders bool
227+
}{responseHeaders: true},
228+
},
229+
{
230+
name: "ok: empty max-age value allowed; mask flips but value stays zero",
231+
chunks: []string{
232+
"origins=https://example.com",
233+
"max-age=",
234+
},
235+
want: storage.CORSConfig{
236+
Origins: []string{"https://example.com"},
237+
MaxAge: 0,
238+
},
239+
wantMask: CORSFieldMask{Origins: true, MaxAge: true},
240+
},
241+
{
242+
name: "ok: only max-age provided",
243+
chunks: []string{
244+
"max-age=1800",
245+
},
246+
want: storage.CORSConfig{MaxAge: 1800},
247+
wantMask: CORSFieldMask{MaxAge: true},
248+
nilFields: struct {
249+
origins, responseHeaders bool
250+
}{origins: true, responseHeaders: true},
251+
},
252+
{
253+
name: "ok: no chunks means zero values; CRD will inject defaults later",
254+
chunks: nil,
255+
want: storage.CORSConfig{MaxAge: 0},
256+
wantMask: CORSFieldMask{},
257+
nilFields: struct {
258+
origins, responseHeaders bool
259+
}{origins: true, responseHeaders: true},
260+
},
261+
{
262+
name: "error: conflicting max-age values",
263+
chunks: []string{
264+
"origins=https://example.com",
265+
"max-age=3600",
266+
"max-age=1800",
267+
},
268+
wantErr: "conflicting max-age values",
269+
},
270+
{
271+
name: "error: invalid max-age (non-int)",
272+
chunks: []string{
273+
"origins=https://example.com",
274+
"max-age=ten",
275+
},
276+
wantErr: "invalid max-age",
277+
},
278+
{
279+
name: "error: unknown key",
280+
chunks: []string{
281+
"origins=https://example.com",
282+
"method=GET",
283+
},
284+
wantErr: "unknown key",
285+
},
286+
{
287+
name: "error: bad segment format (loose tokenizer yields unknown key here)",
288+
chunks: []string{
289+
"origins:https://example.com",
290+
},
291+
wantErr: "unknown key",
292+
},
293+
}
294+
295+
for _, tt := range tests {
296+
t.Run(tt.name, func(t *testing.T) {
297+
got, gotMask, err := parseCORSLooseWithMask(tt.chunks)
298+
299+
if tt.wantErr != "" {
300+
assert.Error(t, err)
301+
assert.Contains(t, err.Error(), tt.wantErr)
302+
return
303+
}
304+
assert.NoError(t, err)
305+
assert.Equal(t, tt.wantMask, gotMask)
306+
307+
if tt.nilFields.origins {
308+
assert.Nil(t, got.Origins, "Origins should be nil")
309+
} else {
310+
assert.Equal(t, tt.want.Origins, got.Origins)
311+
}
312+
if tt.nilFields.responseHeaders {
313+
assert.Nil(t, got.ResponseHeaders, "ResponseHeaders should be nil")
314+
} else {
315+
assert.Equal(t, tt.want.ResponseHeaders, got.ResponseHeaders)
316+
}
317+
318+
assert.Equal(t, tt.want.MaxAge, got.MaxAge)
319+
})
320+
}
321+
}
322+
323+
func TestParseKVPairsStrict(t *testing.T) {
324+
t.Run("ok", func(t *testing.T) {
325+
kv, err := parseSegmentsStrict([]string{"a=1", " b = 2 ", " "})
326+
require.NoError(t, err)
327+
require.Equal(t, []kvPair{
328+
{key: "a", val: "1", segmentIndex: 1, raw: "a=1"},
329+
{key: "b", val: "2", segmentIndex: 2, raw: " b = 2 "},
330+
}, kv)
331+
})
332+
t.Run("error on missing equals", func(t *testing.T) {
333+
_, err := parseSegmentsStrict([]string{"a"})
334+
require.Error(t, err)
335+
require.Contains(t, err.Error(), `must be key=value`)
336+
})
337+
t.Run("error on empty key", func(t *testing.T) {
338+
_, err := parseSegmentsStrict([]string{"=1"})
339+
require.Error(t, err)
340+
require.Contains(t, err.Error(), `empty key`)
341+
})
342+
}
343+
344+
func TestParseKVPairsLoose(t *testing.T) {
345+
kv, err := parseSegmentsLoose([]string{"a", "b=", "c=3", " "})
346+
require.NoError(t, err)
347+
require.Equal(t, []kvPair{
348+
{key: "a", val: "", segmentIndex: 1, raw: "a"},
349+
{key: "b", val: "", segmentIndex: 2, raw: "b="},
350+
{key: "c", val: "3", segmentIndex: 3, raw: "c=3"},
351+
}, kv)
352+
}
353+
354+
func TestParseKVMapLoose(t *testing.T) {
355+
m := parseKVMapLoose("prefix=logs/;is-live=true;note")
356+
require.Equal(t, "logs/", m["prefix"])
357+
require.Equal(t, "true", m["is-live"])
358+
require.Equal(t, "", m["note"])
359+
}

api/util/stringutil.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package util
2+
3+
// ToStrings converts a slice of types which are 'strings' under the hood into
4+
// a []string
5+
func ToStrings[T ~string](in []T) []string {
6+
out := make([]string, len(in))
7+
for i, v := range in {
8+
out[i] = string(v)
9+
}
10+
return out
11+
}

0 commit comments

Comments
 (0)