diff --git a/pkg/stores/partition/store_test.go b/pkg/stores/partition/store_test.go index 21325d7..88f9056 100644 --- a/pkg/stores/partition/store_test.go +++ b/pkg/stores/partition/store_test.go @@ -2,6 +2,7 @@ package partition import ( "context" + "crypto/sha256" "encoding/base64" "fmt" "net/http" @@ -21,20 +22,37 @@ import ( func TestList(t *testing.T) { tests := []struct { - name string - apiOps []*types.APIRequest - partitions []Partition - objects map[string]*unstructured.UnstructuredList - want []types.APIObjectList + name string + apiOps []*types.APIRequest + access []map[string]string + partitions map[string][]Partition + objects map[string]*unstructured.UnstructuredList + want []types.APIObjectList + wantCache []mockCache + disableCache bool + wantListCalls []map[string]int }{ { name: "basic", apiOps: []*types.APIRequest{ newRequest("", "user1"), }, - partitions: []Partition{ - mockPartition{ - name: "all", + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, }, }, objects: map[string]*unstructured.UnstructuredList{ @@ -60,9 +78,22 @@ func TestList(t *testing.T) { newRequest(fmt.Sprintf("limit=1&continue=%s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"p":"all","c":"%s","l":1}`, base64.StdEncoding.EncodeToString([]byte("granny-smith")))))), "user1"), newRequest(fmt.Sprintf("limit=1&continue=%s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"p":"all","c":"%s","l":1}`, base64.StdEncoding.EncodeToString([]byte("crispin")))))), "user1"), }, - partitions: []Partition{ - mockPartition{ - name: "all", + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, }, }, objects: map[string]*unstructured.UnstructuredList{ @@ -102,12 +133,19 @@ func TestList(t *testing.T) { apiOps: []*types.APIRequest{ newRequest("", "user1"), }, - partitions: []Partition{ - mockPartition{ - name: "green", + access: []map[string]string{ + { + "user1": "roleA", }, - mockPartition{ - name: "yellow", + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, }, }, objects: map[string]*unstructured.UnstructuredList{ @@ -144,18 +182,31 @@ func TestList(t *testing.T) { newRequest(fmt.Sprintf("limit=3&continue=%s", base64.StdEncoding.EncodeToString([]byte(`{"p":"green","o":1,"l":3}`))), "user1"), newRequest(fmt.Sprintf("limit=3&continue=%s", base64.StdEncoding.EncodeToString([]byte(`{"p":"red","l":3}`))), "user1"), }, - partitions: []Partition{ - mockPartition{ - name: "pink", + access: []map[string]string{ + { + "user1": "roleA", }, - mockPartition{ - name: "green", + { + "user1": "roleA", }, - mockPartition{ - name: "yellow", + { + "user1": "roleA", }, - mockPartition{ - name: "red", + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "pink", + }, + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, + mockPartition{ + name: "red", + }, }, }, objects: map[string]*unstructured.UnstructuredList{ @@ -216,9 +267,19 @@ func TestList(t *testing.T) { newRequest("filter=data.color=green", "user1"), newRequest("filter=data.color=green&filter=metadata.name=bramley", "user1"), }, - partitions: []Partition{ - mockPartition{ - name: "all", + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, }, }, objects: map[string]*unstructured.UnstructuredList{ @@ -252,15 +313,22 @@ func TestList(t *testing.T) { apiOps: []*types.APIRequest{ newRequest("filter=data.category=baking", "user1"), }, - partitions: []Partition{ - mockPartition{ - name: "pink", + access: []map[string]string{ + { + "user1": "roleA", }, - mockPartition{ - name: "green", - }, - mockPartition{ - name: "yellow", + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "pink", + }, + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, }, }, objects: map[string]*unstructured.UnstructuredList{ @@ -299,9 +367,19 @@ func TestList(t *testing.T) { newRequest("sort=metadata.name", "user1"), newRequest("sort=-metadata.name", "user1"), }, - partitions: []Partition{ - mockPartition{ - name: "all", + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, }, }, objects: map[string]*unstructured.UnstructuredList{ @@ -340,12 +418,19 @@ func TestList(t *testing.T) { apiOps: []*types.APIRequest{ newRequest("sort=metadata.name", "user1"), }, - partitions: []Partition{ - mockPartition{ - name: "green", + access: []map[string]string{ + { + "user1": "roleA", }, - mockPartition{ - name: "yellow", + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, }, }, objects: map[string]*unstructured.UnstructuredList{ @@ -375,17 +460,1178 @@ func TestList(t *testing.T) { }, }, }, + { + name: "pagination", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1&page=2&revision=42", "user1"), + newRequest("pagesize=1&page=3&revision=42", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"all": 1}, + {"all": 1}, + {"all": 1}, + }, + }, + { + name: "access-change pagination", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1&page=2&revision=42", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleB", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleB"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"all": 1}, + {"all": 2}, + }, + }, + { + name: "pagination with cache disabled", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1&page=2&revision=42", "user1"), + newRequest("pagesize=1&page=3&revision=42", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + }, + }, + wantCache: []mockCache{}, + disableCache: true, + wantListCalls: []map[string]int{ + {"all": 1}, + {"all": 2}, + {"all": 3}, + }, + }, + { + name: "multi-partition pagesize=1", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1&page=2&revision=102", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "green", + }, + mockPartition{ + name: "yellow", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "101", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + "green": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "102", + }, + }, + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + }, + }, + "yellow": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "103", + }, + }, + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("crispin").toObj(), + }, + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("crispin").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"green": 1, "yellow": 1}, + {"green": 1, "yellow": 1}, + }, + }, + { + name: "pagesize=1 & limit=2 & continue", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1&limit=2", "user1"), + newRequest("pagesize=1&page=2&limit=2", "user1"), // does not use cache + newRequest("pagesize=1&page=2&revision=42&limit=2", "user1"), // uses cache + newRequest("pagesize=1&page=3&revision=42&limit=2", "user1"), // next page from cache + newRequest(fmt.Sprintf("pagesize=1&revision=42&limit=2&continue=%s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`)))))), "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + { + "user1": "roleA", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + newApple("crispin").Unstructured, + newApple("red-delicious").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "42", + Continue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Continue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Continue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Continue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("crispin").toObj(), + }, + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 2, + resume: "", + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "continue": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 2, + resume: "", + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "continue": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 2, + resume: "", + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "continue": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 2, + resume: "", + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "continue": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 2, + resume: "", + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "continue": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + { + chunkSize: 2, + resume: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"r":"42","p":"all","c":"%s","l":2}`, base64.StdEncoding.EncodeToString([]byte(`crispin`))))), + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("red-delicious").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"all": 2}, + {"all": 4}, + {"all": 4}, + {"all": 4}, + {"all": 5}, + }, + }, + { + name: "multi-user pagination", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1", "user2"), + newRequest("pagesize=1&page=2&revision=42", "user1"), + newRequest("pagesize=1&page=2&revision=42", "user2"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user2": "roleB", + }, + { + "user1": "roleA", + }, + { + "user2": "roleB", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "all", + }, + }, + "user2": { + mockPartition{ + name: "all", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "all": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "42", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("fuji").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "42", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "42", + }: { + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + newApple("granny-smith").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"all": 1}, + {"all": 2}, + {"all": 2}, + {"all": 2}, + }, + }, + { + name: "multi-partition multi-user pagination", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1", "user2"), + newRequest("pagesize=1&page=2&revision=102", "user1"), + newRequest("pagesize=1&page=2&revision=103", "user2"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user2": "roleB", + }, + { + "user1": "roleA", + }, + { + "user2": "roleB", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "green", + }, + }, + "user2": { + mockPartition{ + name: "yellow", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "101", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + "green": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "102", + }, + }, + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + "yellow": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "103", + }, + }, + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "103", + Objects: []types.APIObject{ + newApple("crispin").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("bramley").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "103", + Objects: []types.APIObject{ + newApple("golden-delicious").toObj(), + }, + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + cacheKey{ + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "103", + }: { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "103", + }: { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user2", "roleB"), + resourcePath: "/apples", + revision: "103", + }: { + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"green": 1, "yellow": 0}, + {"green": 1, "yellow": 1}, + {"green": 1, "yellow": 1}, + {"green": 1, "yellow": 1}, + }, + }, + { + name: "multi-partition access-change pagination", + apiOps: []*types.APIRequest{ + newRequest("pagesize=1", "user1"), + newRequest("pagesize=1&page=2&revision=102", "user1"), + }, + access: []map[string]string{ + { + "user1": "roleA", + }, + { + "user1": "roleB", + }, + }, + partitions: map[string][]Partition{ + "user1": { + mockPartition{ + name: "green", + }, + }, + }, + objects: map[string]*unstructured.UnstructuredList{ + "pink": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "101", + }, + }, + Items: []unstructured.Unstructured{ + newApple("fuji").Unstructured, + }, + }, + "green": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "102", + }, + }, + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + "yellow": { + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "resourceVersion": "103", + }, + }, + Items: []unstructured.Unstructured{ + newApple("crispin").Unstructured, + newApple("golden-delicious").Unstructured, + }, + }, + }, + want: []types.APIObjectList{ + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("granny-smith").toObj(), + }, + }, + { + Count: 2, + Pages: 2, + Revision: "102", + Objects: []types.APIObject{ + newApple("bramley").toObj(), + }, + }, + }, + wantCache: []mockCache{ + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + cacheKey{ + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + }, + }, + { + contents: map[cacheKey]*unstructured.UnstructuredList{ + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleA"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + { + chunkSize: 100000, + pageSize: 1, + accessID: getAccessID("user1", "roleB"), + resourcePath: "/apples", + revision: "102", + }: { + Items: []unstructured.Unstructured{ + newApple("granny-smith").Unstructured, + newApple("bramley").Unstructured, + }, + }, + }, + }, + }, + wantListCalls: []map[string]int{ + {"green": 1}, + {"green": 2}, + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { schema := &types.APISchema{Schema: &schemas.Schema{ID: "apple"}} stores := map[string]*mockStore{} - for _, p := range test.partitions { - stores[p.Name()] = &mockStore{ - contents: test.objects[p.Name()], + for _, partitions := range test.partitions { + for _, p := range partitions { + stores[p.Name()] = &mockStore{ + contents: test.objects[p.Name()], + } } } - asl := &mockAccessSetLookup{} + asl := &mockAccessSetLookup{userRoles: test.access} + if test.disableCache { + t.Setenv("CATTLE_REQUEST_CACHE_DISABLED", "Y") + } store := NewStore(mockPartitioner{ stores: stores, partitions: test.partitions, @@ -394,6 +1640,21 @@ func TestList(t *testing.T) { got, gotErr := store.List(req, schema) assert.Nil(t, gotErr) assert.Equal(t, test.want[i], got) + if test.disableCache { + assert.Nil(t, store.listCache) + } + if len(test.wantCache) > 0 { + assert.Equal(t, len(test.wantCache[i].contents), len(store.listCache.Keys())) + for k, v := range test.wantCache[i].contents { + cachedVal, _ := store.listCache.Get(k) + assert.Equal(t, v, cachedVal) + } + } + if len(test.wantListCalls) > 0 { + for name, _ := range store.Partitioner.(mockPartitioner).stores { + assert.Equal(t, test.wantListCalls[i][name], store.Partitioner.(mockPartitioner).stores[name].called) + } + } } }) } @@ -401,7 +1662,7 @@ func TestList(t *testing.T) { type mockPartitioner struct { stores map[string]*mockStore - partitions []Partition + partitions map[string][]Partition } func (m mockPartitioner) Lookup(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) (Partition, error) { @@ -409,7 +1670,8 @@ func (m mockPartitioner) Lookup(apiOp *types.APIRequest, schema *types.APISchema } func (m mockPartitioner) All(apiOp *types.APIRequest, schema *types.APISchema, verb, id string) ([]Partition, error) { - return m.partitions, nil + user, _ := request.UserFrom(apiOp.Request.Context()) + return m.partitions[user.GetName()], nil } func (m mockPartitioner) Store(apiOp *types.APIRequest, partition Partition) (UnstructuredStore, error) { @@ -427,9 +1689,11 @@ func (m mockPartition) Name() string { type mockStore struct { contents *unstructured.UnstructuredList partition mockPartition + called int } func (m *mockStore) List(apiOp *types.APIRequest, schema *types.APISchema) (*unstructured.UnstructuredList, error) { + m.called++ query, _ := url.ParseQuery(apiOp.Request.URL.RawQuery) l := query.Get("limit") if l == "" { @@ -481,6 +1745,10 @@ func (m *mockStore) Watch(apiOp *types.APIRequest, schema *types.APISchema, w ty panic("not implemented") } +type mockCache struct { + contents map[cacheKey]*unstructured.UnstructuredList +} + var colorMap = map[string]string{ "fuji": "pink", "honeycrisp": "pink", @@ -540,14 +1808,25 @@ func (a apple) with(data map[string]string) apple { return a } -type mockAccessSetLookup struct{} +type mockAccessSetLookup struct { + accessID string + userRoles []map[string]string +} -func (m *mockAccessSetLookup) AccessFor(_ user.Info) *accesscontrol.AccessSet { +func (m *mockAccessSetLookup) AccessFor(user user.Info) *accesscontrol.AccessSet { + userName := user.GetName() + access := getAccessID(userName, m.userRoles[0][userName]) + m.userRoles = m.userRoles[1:] return &accesscontrol.AccessSet{ - ID: "aabbccdd", + ID: access, } } func (m *mockAccessSetLookup) PurgeUserData(_ string) { panic("not implemented") } + +func getAccessID(user, role string) string { + h := sha256.Sum256([]byte(user + role)) + return string(h[:]) +}