-
Notifications
You must be signed in to change notification settings - Fork 139
Expand file tree
/
Copy pathnamespace_scope_test.go
More file actions
499 lines (433 loc) · 17.1 KB
/
Copy pathnamespace_scope_test.go
File metadata and controls
499 lines (433 loc) · 17.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
package server
import (
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"github.com/skyhook-io/radar/internal/k8s"
"github.com/skyhook-io/radar/internal/settings"
pkgauth "github.com/skyhook-io/radar/pkg/auth"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// newTestServer constructs a Server with just the state needed by the
// namespace-pick helpers. Avoids the full New() path so we can drive the
// helpers directly without spinning up auth providers or a router.
//
// Only restores the context name on cleanup — does NOT call ResetTestState
// (which would nuke the connection state TestMain established).
func newTestServer(t *testing.T) *Server {
t.Helper()
prev := k8s.SetTestContextName("test-ctx")
t.Cleanup(func() { k8s.SetTestContextName(prev) })
return &Server{}
}
func reqAs(username string) *http.Request {
r := httptest.NewRequest("GET", "/api/cluster/namespace", nil)
if username != "" {
ctx := pkgauth.ContextWithUser(r.Context(), &pkgauth.User{Username: username})
r = r.WithContext(ctx)
}
return r
}
func TestNsPreferenceKey_PerUserIsolation(t *testing.T) {
// Different users must produce distinct keys. Without the username,
// one user's pick would shadow another's.
if nsPreferenceKey("alice", "ctx") == nsPreferenceKey("bob", "ctx") {
t.Error("alice and bob produced the same nsPreferenceKey")
}
// Same user, same context — keys must match.
if nsPreferenceKey("alice", "ctx") != nsPreferenceKey("alice", "ctx") {
t.Error("nsPreferenceKey is not deterministic")
}
// Empty username (no-auth) collapses to a per-context key.
if nsPreferenceKey("", "ctx-a") == nsPreferenceKey("", "ctx-b") {
t.Error("no-auth keys for different contexts should differ")
}
// Substring confusion: alice/foo must not collide with alic/efoo etc.
// The \x00 separator makes this safe — verify by counterexample.
if nsPreferenceKey("alice", "foo") == nsPreferenceKey("ali", "cefoo") {
t.Error("nsPreferenceKey separator is ambiguous")
}
}
func TestSetAndGetActiveNamespaceForUser_PerUser(t *testing.T) {
s := newTestServer(t)
// Alice picks alpha; Bob picks beta + gamma. Each must read back their own picks.
s.setActiveNamespaceForUser(reqAs("alice"), []string{"alpha"})
s.setActiveNamespaceForUser(reqAs("bob"), []string{"beta", "gamma"})
if got := s.getActiveNamespaceForUser(reqAs("alice")); !slices.Equal(got, []string{"alpha"}) {
t.Errorf("alice: got %v, want [alpha]", got)
}
if got := s.getActiveNamespaceForUser(reqAs("bob")); !slices.Equal(got, []string{"beta", "gamma"}) {
t.Errorf("bob: got %v, want [beta gamma]", got)
}
// A third user with no pick gets the empty default.
if got := s.getActiveNamespaceForUser(reqAs("carol")); len(got) != 0 {
t.Errorf("carol: expected empty pick, got %v", got)
}
}
func TestSetActiveNamespaceForUser_EmptyClears(t *testing.T) {
s := newTestServer(t)
s.setActiveNamespaceForUser(reqAs("alice"), []string{"alpha", "beta"})
s.setActiveNamespaceForUser(reqAs("alice"), nil) // clear
if got := s.getActiveNamespaceForUser(reqAs("alice")); len(got) != 0 {
t.Errorf("expected empty after nil-clear, got %v", got)
}
s.setActiveNamespaceForUser(reqAs("alice"), []string{"alpha"})
s.setActiveNamespaceForUser(reqAs("alice"), []string{}) // empty slice also clears
if got := s.getActiveNamespaceForUser(reqAs("alice")); len(got) != 0 {
t.Errorf("expected empty after empty-slice clear, got %v", got)
}
}
func TestSetActiveNamespaceForUser_NoAuth(t *testing.T) {
s := newTestServer(t)
// Auth disabled — empty username path. The key is still per-context.
s.setActiveNamespaceForUser(reqAs(""), []string{"alpha"})
if got := s.getActiveNamespaceForUser(reqAs("")); !slices.Equal(got, []string{"alpha"}) {
t.Errorf("no-auth: got %v, want [alpha]", got)
}
}
func TestSetActiveNamespaceForUser_DefensiveCopy(t *testing.T) {
// Mutating the caller's slice after a Set must not corrupt stored state.
s := newTestServer(t)
picks := []string{"alpha", "beta"}
s.setActiveNamespaceForUser(reqAs("alice"), picks)
picks[0] = "MUTATED"
got := s.getActiveNamespaceForUser(reqAs("alice"))
if !slices.Equal(got, []string{"alpha", "beta"}) {
t.Errorf("stored picks were mutated by caller: got %v", got)
}
}
func TestSetActiveNamespaceForUser_NoContext(t *testing.T) {
// When no kubeconfig context is set (e.g. before initial connection),
// set/get must be no-ops — there's no cluster to scope to.
prev := k8s.SetTestContextName("")
t.Cleanup(func() { k8s.SetTestContextName(prev) })
s := &Server{}
s.setActiveNamespaceForUser(reqAs("alice"), []string{"alpha"})
if got := s.getActiveNamespaceForUser(reqAs("alice")); len(got) != 0 {
t.Errorf("expected empty without context, got %v", got)
}
}
func TestClearAllNamespacePreferences(t *testing.T) {
s := newTestServer(t)
s.setActiveNamespaceForUser(reqAs("alice"), []string{"alpha"})
s.setActiveNamespaceForUser(reqAs("bob"), []string{"beta", "gamma"})
s.setActiveNamespaceForUser(reqAs(""), []string{"delta"})
s.clearAllNamespacePreferences()
for _, user := range []string{"alice", "bob", ""} {
if got := s.getActiveNamespaceForUser(reqAs(user)); len(got) != 0 {
t.Errorf("user=%q: expected cleared, got %v", user, got)
}
}
}
func TestFinalizePostContextSwitch_ClearsBothCaches(t *testing.T) {
// Pin the load-bearing claim from the comment on finalizePostContextSwitch:
// it MUST clear permCache AND every user's namespace pick. A regression
// that drops either side leaves stale state attached to the new cluster.
s := newTestServer(t)
s.permCache = pkgauth.NewPermissionCache()
s.permCache.Set("alice", &pkgauth.UserPermissions{AllowedNamespaces: []string{"alpha"}})
s.setActiveNamespaceForUser(reqAs("alice"), []string{"alpha"})
s.setActiveNamespaceForUser(reqAs("bob"), []string{"beta", "gamma"})
s.finalizePostContextSwitch()
if got := s.permCache.Get("alice"); got != nil {
t.Errorf("permCache.Get(alice) = %+v after finalize, want nil", got)
}
if got := s.getActiveNamespaceForUser(reqAs("alice")); len(got) != 0 {
t.Errorf("alice ns pick survived: %v", got)
}
if got := s.getActiveNamespaceForUser(reqAs("bob")); len(got) != 0 {
t.Errorf("bob ns pick survived: %v", got)
}
}
func TestFinalizePostContextSwitch_NilPermCacheNoCrash(t *testing.T) {
// finalizePostContextSwitch is called from CAPI connect / context switch
// before s.permCache may have been initialized in some paths; guarding
// nil is the contract.
s := newTestServer(t)
s.permCache = nil
s.setActiveNamespaceForUser(reqAs("alice"), []string{"alpha"})
s.finalizePostContextSwitch() // must not panic
if got := s.getActiveNamespaceForUser(reqAs("alice")); len(got) != 0 {
t.Errorf("ns pick survived nil-permCache finalize: %v", got)
}
}
func TestClearAllNamespacePreferences_OnContextSwitch(t *testing.T) {
// Picks made under context A must not survive a switch to context B —
// they reference namespaces that don't exist on the new cluster.
s := newTestServer(t)
k8s.SetTestContextName("ctx-a")
s.setActiveNamespaceForUser(reqAs("alice"), []string{"alpha", "beta"})
// Switch context (callers do this via PerformContextSwitch which calls
// clearAllNamespacePreferences before swapping context).
s.clearAllNamespacePreferences()
k8s.SetTestContextName("ctx-b")
if got := s.getActiveNamespaceForUser(reqAs("alice")); len(got) != 0 {
t.Errorf("pick survived context switch: got %v", got)
}
}
func TestIntersectPicksWithAllowed(t *testing.T) {
tests := []struct {
name string
picks []string
allowed []string
want []string
}{
{
name: "empty picks returns nil (no narrowing)",
picks: nil,
allowed: []string{"alpha", "beta"},
want: nil,
},
{
name: "nil allowed = cluster-admin pass-through",
picks: []string{"alpha", "beta"},
allowed: nil,
want: []string{"alpha", "beta"},
},
{
name: "all picks allowed",
picks: []string{"alpha", "beta"},
allowed: []string{"alpha", "beta", "gamma"},
want: []string{"alpha", "beta"},
},
{
name: "partial revocation drops only stale entries",
picks: []string{"alpha", "beta", "gamma"},
allowed: []string{"alpha", "gamma"},
want: []string{"alpha", "gamma"},
},
{
name: "full revocation returns empty (caller decides to clear)",
picks: []string{"alpha", "beta"},
allowed: []string{"gamma", "delta"},
want: []string{},
},
{
name: "preserves pick order",
picks: []string{"gamma", "alpha", "beta"},
allowed: []string{"alpha", "beta", "gamma"},
want: []string{"gamma", "alpha", "beta"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := intersectPicksWithAllowed(tt.picks, tt.allowed)
if !slices.Equal(got, tt.want) {
t.Errorf("intersectPicksWithAllowed(%v, %v) = %v, want %v", tt.picks, tt.allowed, got, tt.want)
}
})
}
}
func TestResolveHelmNamespaces_NoAuthUsesBackendFallback(t *testing.T) {
s := newTestServer(t)
restoreHelmNamespaceFallbackState(t)
got, ok := s.resolveHelmNamespaces(reqAs(""))
if !ok {
t.Fatal("resolveHelmNamespaces returned ok=false")
}
if !slices.Equal(got, []string{"backend-fallback"}) {
t.Fatalf("namespaces = %v, want backend fallback namespace", got)
}
}
func TestResolveHelmNamespaces_AuthenticatedClusterWideUserDoesNotUseBackendFallback(t *testing.T) {
s := newTestServer(t)
restoreHelmNamespaceFallbackState(t)
s.permCache = pkgauth.NewPermissionCache()
s.permCache.Set("alice", &pkgauth.UserPermissions{AllowedNamespaces: nil})
got, ok := s.resolveHelmNamespaces(reqAs("alice"))
if !ok {
t.Fatal("resolveHelmNamespaces returned ok=false")
}
if got != nil {
t.Fatalf("namespaces = %v, want nil so Helm lists as the impersonated user cluster-wide", got)
}
}
func restoreHelmNamespaceFallbackState(t *testing.T) {
t.Helper()
prevTimeout := k8s.NamespaceListTimeout
k8s.NamespaceListTimeout = 100 * time.Millisecond
t.Cleanup(func() { k8s.NamespaceListTimeout = prevTimeout })
prevClient := k8s.SetTestClient(nil)
t.Cleanup(func() { k8s.SetTestClient(prevClient) })
dummyClient, err := kubernetes.NewForConfig(&rest.Config{Host: "http://127.0.0.1:1"})
if err != nil {
t.Fatalf("creating dummy client: %v", err)
}
k8s.SetTestClient(dummyClient)
k8s.SetFallbackNamespace("backend-fallback")
t.Cleanup(func() { k8s.SetFallbackNamespace("") })
}
// The shared TestMain cache holds exactly two namespaces: "default" and
// "broken". Anything else counts as deleted from the cluster.
func TestParseNamespacesForUser_EvictsDeletedSavedPick(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s := newTestServer(t)
// A pick saved in a previous session names a namespace that has since
// been deleted from the cluster.
if _, err := settings.Update(func(st *settings.Settings) {
st.ActiveNamespaces = map[string][]string{"test-ctx": {"ghost"}}
}); err != nil {
t.Fatalf("settings.Update: %v", err)
}
req := httptest.NewRequest("GET", "/api/resources/pods", nil)
if got := s.parseNamespacesForUser(req); got != nil {
t.Fatalf("parseNamespacesForUser = %v, want nil (unfiltered) after stale-pick eviction", got)
}
if picks := s.getActiveNamespaceForUser(reqAs("")); len(picks) != 0 {
t.Errorf("stale pick survived in memory: %v", picks)
}
// The eviction must reach settings.json — otherwise loadSavedNamespace-
// Preference re-seeds the stale pick on the next request.
if saved := settings.Load().ActiveNamespaces["test-ctx"]; len(saved) != 0 {
t.Errorf("stale pick survived in settings: %v", saved)
}
}
func TestParseNamespacesForUser_TrimsPartiallyDeletedPick(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s := newTestServer(t)
if _, err := settings.Update(func(st *settings.Settings) {
st.ActiveNamespaces = map[string][]string{"test-ctx": {"default", "ghost"}}
}); err != nil {
t.Fatalf("settings.Update: %v", err)
}
req := httptest.NewRequest("GET", "/api/resources/pods", nil)
if got := s.parseNamespacesForUser(req); !slices.Equal(got, []string{"default"}) {
t.Fatalf("parseNamespacesForUser = %v, want [default]", got)
}
if picks := s.getActiveNamespaceForUser(reqAs("")); !slices.Equal(picks, []string{"default"}) {
t.Errorf("in-memory pick = %v, want [default]", picks)
}
if saved := settings.Load().ActiveNamespaces["test-ctx"]; !slices.Equal(saved, []string{"default"}) {
t.Errorf("saved pick = %v, want [default]", saved)
}
}
func TestParseNamespacesForUser_ValidSavedPickStillFilters(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s := newTestServer(t)
s.setActiveNamespaceForUser(reqAs(""), []string{"default"})
req := httptest.NewRequest("GET", "/api/resources/pods", nil)
if got := s.parseNamespacesForUser(req); !slices.Equal(got, []string{"default"}) {
t.Fatalf("parseNamespacesForUser = %v, want [default]", got)
}
if picks := s.getActiveNamespaceForUser(reqAs("")); !slices.Equal(picks, []string{"default"}) {
t.Errorf("valid pick was disturbed: %v", picks)
}
}
func TestPruneToExistingNamespaces(t *testing.T) {
tests := []struct {
name string
picks []string
existing []string
want []string
}{
{
name: "nil existing (informer unavailable) leaves picks alone",
picks: []string{"alpha", "beta"},
existing: nil,
want: []string{"alpha", "beta"},
},
{
name: "empty existing leaves picks alone",
picks: []string{"alpha"},
existing: []string{},
want: []string{"alpha"},
},
{
name: "deleted namespace dropped, survivors kept",
picks: []string{"alpha", "ghost"},
existing: []string{"alpha", "beta"},
want: []string{"alpha"},
},
{
name: "all deleted returns empty",
picks: []string{"ghost", "phantom"},
existing: []string{"alpha"},
want: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pruneToExistingNamespaces(tt.picks, tt.existing)
if !slices.Equal(got, tt.want) {
t.Errorf("pruneToExistingNamespaces(%v, %v) = %v, want %v", tt.picks, tt.existing, got, tt.want)
}
})
}
}
func TestParseNamespacesForUser_ForcedCacheScope(t *testing.T) {
s := newTestServer(t)
k8s.ForceNamespaceScope = true
k8s.SetFallbackNamespace("prod")
t.Cleanup(func() {
k8s.ForceNamespaceScope = false
k8s.SetFallbackNamespace("")
})
cases := []struct {
name string
url string
want []string
}{
{name: "no query uses cache scope", url: "/api/resources/pods", want: []string{"prod"}},
{name: "query including cache scope narrows to cache scope", url: "/api/resources/pods?namespaces=prod,staging", want: []string{"prod"}},
{name: "query outside cache scope returns no access", url: "/api/resources/pods?namespace=staging", want: []string{}},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.url, nil)
if got := s.parseNamespacesForUser(req); !slices.Equal(got, tt.want) {
t.Fatalf("parseNamespacesForUser(%q) = %v, want %v", tt.url, got, tt.want)
}
})
}
}
// The prune validates a snapshot of the pick; if a concurrent POST replaces
// the pick before the prune's write, the write must be skipped — otherwise a
// slow read reverts the user's fresh pick to pruned survivors of the old one.
func TestPruneDeletedNamespacePicks_SkipsWhenPickChangedMidFlight(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s := newTestServer(t)
req := reqAs("")
// The stale snapshot a slow read is working from.
snapshot := []string{"default", "ghost"}
s.setActiveNamespaceForUser(req, snapshot)
// A user POST lands while the read is pruning its snapshot.
s.setActiveNamespaceForUser(req, []string{"broken"})
survivors := s.pruneDeletedNamespacePicks(req, snapshot)
if !slices.Equal(survivors, []string{"default"}) {
t.Fatalf("survivors = %v, want [default] (this request still filters by its own snapshot)", survivors)
}
// The fresh pick must be untouched — the prune's write was skipped.
if picks := s.getActiveNamespaceForUser(req); !slices.Equal(picks, []string{"broken"}) {
t.Errorf("fresh pick reverted by stale prune: %v, want [broken]", picks)
}
if saved := settings.Load().ActiveNamespaces["test-ctx"]; len(saved) != 0 {
t.Errorf("stale prune persisted survivors over the fresh pick: %v", saved)
}
}
// A context switch between snapshot and write must also skip the mutation —
// old-context survivors must not persist under the new context's key.
func TestPruneDeletedNamespacePicks_SkipsAcrossContextSwitch(t *testing.T) {
t.Setenv("HOME", t.TempDir())
s := newTestServer(t)
req := reqAs("")
snapshot := []string{"default", "ghost"}
s.setActiveNamespaceForUser(req, snapshot)
// Simulate the interleaving: the prune's entry snapshot saw "test-ctx";
// wrap the call so the context flips before it runs (the guard re-reads
// the context under the lock, so a flip before the call exercises the
// same skip path as one mid-call).
prev := k8s.SetTestContextName("other-ctx")
survivorsUnderOther := s.pruneDeletedNamespacePicks(req, snapshot)
k8s.SetTestContextName(prev)
_ = survivorsUnderOther
// The original context's pick and settings are untouched.
if picks := s.getActiveNamespaceForUser(req); !slices.Equal(picks, snapshot) {
t.Errorf("old-context pick mutated across switch: %v, want %v", picks, snapshot)
}
if saved := settings.Load().ActiveNamespaces["other-ctx"]; len(saved) != 0 {
t.Errorf("survivors persisted under the new context's key: %v", saved)
}
}