/*
Copyright 2017 Heptio Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package backup

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"encoding/json"
	"errors"
	"io"
	"reflect"
	"sort"
	"testing"
	"time"

	"k8s.io/apimachinery/pkg/api/meta"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/util/sets"

	"github.com/heptio/ark/pkg/apis/ark/v1"
	"github.com/heptio/ark/pkg/util/collections"
	. "github.com/heptio/ark/pkg/util/test"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
)

type fakeAction struct {
	ids     []string
	backups []*v1.Backup
}

var _ Action = &fakeAction{}

func (a *fakeAction) Execute(item map[string]interface{}, backup *v1.Backup) error {
	metadata, err := collections.GetMap(item, "metadata")
	if err != nil {
		return err
	}

	var id string

	if v, ok := metadata["namespace"]; ok {
		id = v.(string) + "/"
	}

	if v, ok := metadata["name"]; ok {
		id += v.(string)
	}

	a.ids = append(a.ids, id)
	a.backups = append(a.backups, backup)

	return nil
}

func TestResolveActions(t *testing.T) {
	tests := []struct {
		name                string
		input               map[string]Action
		expected            map[schema.GroupResource]Action
		resourcesWithErrors []string
		expectError         bool
	}{
		{
			name:     "empty input",
			input:    map[string]Action{},
			expected: map[schema.GroupResource]Action{},
		},
		{
			name:        "mapper error",
			input:       map[string]Action{"badresource": &fakeAction{}},
			expected:    map[schema.GroupResource]Action{},
			expectError: true,
		},
		{
			name:  "resolved",
			input: map[string]Action{"foo": &fakeAction{}, "bar": &fakeAction{}},
			expected: map[schema.GroupResource]Action{
				schema.GroupResource{Group: "somegroup", Resource: "foodies"}:      &fakeAction{},
				schema.GroupResource{Group: "anothergroup", Resource: "barnacles"}: &fakeAction{},
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			mapper := &FakeMapper{
				Resources: map[schema.GroupVersionResource]schema.GroupVersionResource{
					schema.GroupVersionResource{Resource: "foo"}: schema.GroupVersionResource{Group: "somegroup", Resource: "foodies"},
					schema.GroupVersionResource{Resource: "fie"}: schema.GroupVersionResource{Group: "somegroup", Resource: "fields"},
					schema.GroupVersionResource{Resource: "bar"}: schema.GroupVersionResource{Group: "anothergroup", Resource: "barnacles"},
					schema.GroupVersionResource{Resource: "baz"}: schema.GroupVersionResource{Group: "anothergroup", Resource: "bazaars"},
				},
			}

			actual, err := resolveActions(mapper, test.input)
			gotError := err != nil

			if e, a := test.expectError, gotError; e != a {
				t.Fatalf("error: expected %t, got %t", e, a)
			}
			if test.expectError {
				return
			}

			if e, a := test.expected, actual; !reflect.DeepEqual(e, a) {
				t.Errorf("expected %v, got %v", e, a)
			}
		})
	}
}

func TestGetResourceIncludesExcludes(t *testing.T) {
	tests := []struct {
		name                string
		includes            []string
		excludes            []string
		resourcesWithErrors []string
		expectedIncludes    []string
		expectedExcludes    []string
	}{
		{
			name:             "no input",
			expectedIncludes: []string{},
			expectedExcludes: []string{},
		},
		{
			name:             "wildcard includes",
			includes:         []string{"*", "asdf"},
			excludes:         []string{},
			expectedIncludes: []string{"*"},
			expectedExcludes: []string{},
		},
		{
			name:             "wildcard excludes aren't allowed or resolved",
			includes:         []string{},
			excludes:         []string{"*"},
			expectedIncludes: []string{},
			expectedExcludes: []string{},
		},
		{
			name:             "resolution works",
			includes:         []string{"foo", "fie"},
			excludes:         []string{"bar", "baz"},
			expectedIncludes: []string{"foodies.somegroup", "fields.somegroup"},
			expectedExcludes: []string{"barnacles.anothergroup", "bazaars.anothergroup"},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			mapper := &FakeMapper{
				Resources: map[schema.GroupVersionResource]schema.GroupVersionResource{
					schema.GroupVersionResource{Resource: "foo"}: schema.GroupVersionResource{Group: "somegroup", Resource: "foodies"},
					schema.GroupVersionResource{Resource: "fie"}: schema.GroupVersionResource{Group: "somegroup", Resource: "fields"},
					schema.GroupVersionResource{Resource: "bar"}: schema.GroupVersionResource{Group: "anothergroup", Resource: "barnacles"},
					schema.GroupVersionResource{Resource: "baz"}: schema.GroupVersionResource{Group: "anothergroup", Resource: "bazaars"},
				},
			}

			backup := &v1.Backup{
				Spec: v1.BackupSpec{
					IncludedResources: test.includes,
					ExcludedResources: test.excludes,
				},
			}

			actual := getResourceIncludesExcludes(mapper, backup)

			sort.Strings(test.expectedIncludes)
			actualIncludes := actual.GetIncludes()
			sort.Strings(actualIncludes)
			if e, a := test.expectedIncludes, actualIncludes; !reflect.DeepEqual(e, a) {
				t.Errorf("includes: expected %v, got %v", e, a)
			}

			sort.Strings(test.expectedExcludes)
			actualExcludes := actual.GetExcludes()
			sort.Strings(actualExcludes)
			if e, a := test.expectedExcludes, actualExcludes; !reflect.DeepEqual(e, a) {
				t.Errorf("excludes: expected %v, got %v", e, a)
				t.Errorf("excludes: expected %v, got %v", len(e), len(a))
			}
		})
	}
}

func TestGetNamespaceIncludesExcludes(t *testing.T) {
	backup := &v1.Backup{
		Spec: v1.BackupSpec{
			IncludedResources:  []string{"foo", "bar"},
			ExcludedResources:  []string{"fie", "baz"},
			IncludedNamespaces: []string{"a", "b", "c"},
			ExcludedNamespaces: []string{"d", "e", "f"},
			TTL:                metav1.Duration{Duration: 1 * time.Hour},
		},
	}

	ns := getNamespaceIncludesExcludes(backup)

	actualIncludes := ns.GetIncludes()
	sort.Strings(actualIncludes)
	if e, a := backup.Spec.IncludedNamespaces, actualIncludes; !reflect.DeepEqual(e, a) {
		t.Errorf("includes: expected %v, got %v", e, a)
	}

	actualExcludes := ns.GetExcludes()
	sort.Strings(actualExcludes)
	if e, a := backup.Spec.ExcludedNamespaces, actualExcludes; !reflect.DeepEqual(e, a) {
		t.Errorf("excludes: expected %v, got %v", e, a)
	}
}

type fakeDiscoveryHelper struct {
	resources []*metav1.APIResourceList
	mapper    meta.RESTMapper
}

func (dh *fakeDiscoveryHelper) Resources() []*metav1.APIResourceList {
	return dh.resources
}

func (dh *fakeDiscoveryHelper) Mapper() meta.RESTMapper {
	return dh.mapper
}

func (dh *fakeDiscoveryHelper) Refresh() error {
	return nil
}

func TestBackupMethod(t *testing.T) {
	// TODO ensure LabelSelector is passed through to the List() calls
	backup := &v1.Backup{
		Spec: v1.BackupSpec{
			// cm - shortcut in legacy api group, namespaced
			// csr - shortcut in certificates.k8s.io api group, cluster-scoped
			// roles - fully qualified in rbac.authorization.k8s.io api group, namespaced
			IncludedResources:  []string{"cm", "csr", "roles"},
			IncludedNamespaces: []string{"a", "b"},
			ExcludedNamespaces: []string{"c", "d"},
		},
	}

	configMapsResource := metav1.APIResource{
		Name:         "configmaps",
		SingularName: "configmap",
		Namespaced:   true,
		Kind:         "ConfigMap",
		Verbs:        metav1.Verbs([]string{"create", "update", "get", "list", "watch", "delete"}),
		ShortNames:   []string{"cm"},
		Categories:   []string{"all"},
	}

	podsResource := metav1.APIResource{
		Name:         "pods",
		SingularName: "pod",
		Namespaced:   true,
		Kind:         "Pod",
		Verbs:        metav1.Verbs([]string{"create", "update", "get", "list", "watch", "delete"}),
		ShortNames:   []string{"po"},
		Categories:   []string{"all"},
	}

	rolesResource := metav1.APIResource{
		Name:         "roles",
		SingularName: "role",
		Namespaced:   true,
		Kind:         "Role",
		Verbs:        metav1.Verbs([]string{"create", "update", "get", "list", "watch", "delete"}),
	}

	certificateSigningRequestsResource := metav1.APIResource{
		Name:         "certificatesigningrequests",
		SingularName: "certificatesigningrequest",
		Namespaced:   false,
		Kind:         "CertificateSigningRequest",
		Verbs:        metav1.Verbs([]string{"create", "update", "get", "list", "watch", "delete"}),
		ShortNames:   []string{"csr"},
	}

	discoveryHelper := &fakeDiscoveryHelper{
		mapper: &FakeMapper{
			Resources: map[schema.GroupVersionResource]schema.GroupVersionResource{
				schema.GroupVersionResource{Resource: "cm"}:    schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"},
				schema.GroupVersionResource{Resource: "csr"}:   schema.GroupVersionResource{Group: "certificates.k8s.io", Version: "v1beta1", Resource: "certificatesigningrequests"},
				schema.GroupVersionResource{Resource: "roles"}: schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1beta1", Resource: "roles"},
			},
		},
		resources: []*metav1.APIResourceList{
			{
				GroupVersion: "v1",
				APIResources: []metav1.APIResource{configMapsResource, podsResource},
			},
			{
				GroupVersion: "certificates.k8s.io/v1beta1",
				APIResources: []metav1.APIResource{certificateSigningRequestsResource},
			},
			{
				GroupVersion: "rbac.authorization.k8s.io/v1beta1",
				APIResources: []metav1.APIResource{rolesResource},
			},
		},
	}

	dynamicFactory := &FakeDynamicFactory{}

	legacyGV := schema.GroupVersionResource{Version: "v1"}

	configMapsClientA := &FakeDynamicClient{}
	configMapsA := toRuntimeObject(t, `{
		"apiVersion": "v1",
		"kind": "ConfigMapList",
		"items": [
		  {
		  	"metadata": {
		  		"namespace":"a",
					"name":"configMap1"
				},
				"data": {
					"a": "b"
				}
			}
		]
	}`)
	configMapsClientA.On("List", metav1.ListOptions{}).Return(configMapsA, nil)
	dynamicFactory.On("ClientForGroupVersionResource", legacyGV, configMapsResource, "a").Return(configMapsClientA, nil)

	configMapsClientB := &FakeDynamicClient{}
	configMapsB := toRuntimeObject(t, `{
		"apiVersion": "v1",
		"kind": "ConfigMapList",
		"items": [
		  {
		  	"metadata": {
		  		"namespace":"b",
					"name":"configMap2"
				},
				"data": {
					"c": "d"
				}
			}
		]
	}`)
	configMapsClientB.On("List", metav1.ListOptions{}).Return(configMapsB, nil)
	dynamicFactory.On("ClientForGroupVersionResource", legacyGV, configMapsResource, "b").Return(configMapsClientB, nil)

	certificatesGV := schema.GroupVersionResource{Group: "certificates.k8s.io", Version: "v1beta1"}

	csrList := toRuntimeObject(t, `{
		"apiVersion": "certificates.k8s.io/v1beta1",
		"kind": "CertificateSigningRequestList",
		"items": [
			{
				"metadata": {
					"name": "csr1"
				},
				"spec": {
					"request": "some request",
					"username": "bob",
					"uid": "12345",
					"groups": [
						"group1",
						"group2"
					]
				},
				"status": {
					"certificate": "some cert"
				}
			}
		]
	}`)
	csrClient := &FakeDynamicClient{}
	csrClient.On("List", metav1.ListOptions{}).Return(csrList, nil)
	dynamicFactory.On("ClientForGroupVersionResource", certificatesGV, certificateSigningRequestsResource, "").Return(csrClient, nil)

	roleListA := toRuntimeObject(t, `{
		"apiVersion": "rbac.authorization.k8s.io/v1beta1",
		"kind": "RoleList",
		"items": [
			{
				"metadata": {
					"namespace": "a",
					"name": "role1"
				},
				"rules": [
					{
						"verbs": ["get","list"],
						"apiGroups": ["apps","extensions"],
						"resources": ["deployments"]
					}
				]
			}
		]
	}`)

	roleListB := toRuntimeObject(t, `{
		"apiVersion": "rbac.authorization.k8s.io/v1beta1",
		"kind": "RoleList",
		"items": []
	}`)

	rbacGV := schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1beta1"}

	rolesClientA := &FakeDynamicClient{}
	rolesClientA.On("List", metav1.ListOptions{}).Return(roleListA, nil)
	dynamicFactory.On("ClientForGroupVersionResource", rbacGV, rolesResource, "a").Return(rolesClientA, nil)
	rolesClientB := &FakeDynamicClient{}
	rolesClientB.On("List", metav1.ListOptions{}).Return(roleListB, nil)
	dynamicFactory.On("ClientForGroupVersionResource", rbacGV, rolesResource, "b").Return(rolesClientB, nil)

	cmAction := &fakeAction{}
	csrAction := &fakeAction{}

	actions := map[string]Action{
		"cm":  cmAction,
		"csr": csrAction,
	}

	backupper, err := NewKubernetesBackupper(discoveryHelper, dynamicFactory, actions)
	require.NoError(t, err)

	output := new(bytes.Buffer)
	err = backupper.Backup(backup, output)
	require.NoError(t, err)

	expectedFiles := sets.NewString(
		"namespaces/a/configmaps/configMap1.json",
		"namespaces/b/configmaps/configMap2.json",
		"cluster/certificatesigningrequests.certificates.k8s.io/csr1.json",
		"namespaces/a/roles.rbac.authorization.k8s.io/role1.json",
	)

	expectedData := map[string]string{
		"namespaces/a/configmaps/configMap1.json": `
			{
				"apiVersion": "v1",
				"kind": "ConfigMap",
				"metadata": {
					"namespace":"a",
					"name":"configMap1"
				},
				"data": {
					"a": "b"
				}
			}`,
		"namespaces/b/configmaps/configMap2.json": `
			{
				"apiVersion": "v1",
				"kind": "ConfigMap",
				"metadata": {
					"namespace":"b",
					"name":"configMap2"
				},
				"data": {
					"c": "d"
				}
			}
		`,
		"cluster/certificatesigningrequests.certificates.k8s.io/csr1.json": `
			{
				"apiVersion": "certificates.k8s.io/v1beta1",
				"kind": "CertificateSigningRequest",
				"metadata": {
					"name": "csr1"
				},
				"spec": {
					"request": "some request",
					"username": "bob",
					"uid": "12345",
					"groups": [
						"group1",
						"group2"
					]
				}
			}
		`,
		"namespaces/a/roles.rbac.authorization.k8s.io/role1.json": `
			{
				"apiVersion": "rbac.authorization.k8s.io/v1beta1",
				"kind": "Role",
				"metadata": {
					"namespace":"a",
					"name": "role1"
				},
				"rules": [
					{
						"verbs": ["get","list"],
						"apiGroups": ["apps","extensions"],
						"resources": ["deployments"]
					}
				]
			}
		`,
	}

	seenFiles := sets.NewString()

	gzipReader, err := gzip.NewReader(output)
	require.NoError(t, err)
	defer gzipReader.Close()

	tarReader := tar.NewReader(gzipReader)
	for {
		header, err := tarReader.Next()
		if err == io.EOF {
			break
		}
		require.NoError(t, err)

		switch header.Typeflag {
		case tar.TypeReg:
			seenFiles.Insert(header.Name)
			expected, err := getAsMap(expectedData[header.Name])
			if !assert.NoError(t, err, "%q: %v", header.Name, err) {
				continue
			}

			buf := new(bytes.Buffer)
			n, err := io.Copy(buf, tarReader)
			if !assert.NoError(t, err) {
				continue
			}

			if !assert.Equal(t, header.Size, n) {
				continue
			}

			actual, err := getAsMap(string(buf.Bytes()))
			if !assert.NoError(t, err) {
				continue
			}
			assert.Equal(t, expected, actual)
		default:
			t.Errorf("unexpected header: %#v", header)
		}
	}

	if !expectedFiles.Equal(seenFiles) {
		t.Errorf("did not get expected files. expected-seen: %v. seen-expected: %v", expectedFiles.Difference(seenFiles), seenFiles.Difference(expectedFiles))
	}

	expectedCMActionIDs := []string{"a/configMap1", "b/configMap2"}
	expectedCSRActionIDs := []string{"csr1"}

	assert.Equal(t, expectedCMActionIDs, cmAction.ids)
	assert.Equal(t, expectedCSRActionIDs, csrAction.ids)
}

func TestBackupResource(t *testing.T) {
	tests := []struct {
		name                            string
		resourceIncludesExcludes        *collections.IncludesExcludes
		resourceGroup                   string
		resourceVersion                 string
		resourceGV                      string
		resourceName                    string
		resourceNamespaced              bool
		namespaceIncludesExcludes       *collections.IncludesExcludes
		expectedListedNamespaces        []string
		lists                           []string
		labelSelector                   string
		actions                         map[string]Action
		expectedActionIDs               map[string][]string
		deploymentsBackedUp             bool
		expectedDeploymentsBackedUp     bool
		networkPoliciesBackedUp         bool
		expectedNetworkPoliciesBackedUp bool
	}{
		{
			name: "should not include resource",
			resourceIncludesExcludes: collections.NewIncludesExcludes().Includes("pods"),
			resourceGV:               "v1",
			resourceName:             "secrets",
			resourceNamespaced:       true,
		},
		{
			name: "should skip deployments.extensions if we've seen deployments.apps",
			resourceIncludesExcludes:    collections.NewIncludesExcludes().Includes("*"),
			resourceGV:                  "extensions/v1beta1",
			resourceName:                "deployments",
			resourceNamespaced:          true,
			deploymentsBackedUp:         true,
			expectedDeploymentsBackedUp: true,
		},
		{
			name: "should skip deployments.apps if we've seen deployments.extensions",
			resourceIncludesExcludes:    collections.NewIncludesExcludes().Includes("*"),
			resourceGV:                  "apps/v1beta1",
			resourceName:                "deployments",
			resourceNamespaced:          true,
			deploymentsBackedUp:         true,
			expectedDeploymentsBackedUp: true,
		},
		{
			name: "should skip networkpolicies.extensions if we've seen networkpolicies.networking.k8s.io",
			resourceIncludesExcludes:        collections.NewIncludesExcludes().Includes("*"),
			resourceGV:                      "extensions/v1beta1",
			resourceName:                    "networkpolicies",
			resourceNamespaced:              true,
			networkPoliciesBackedUp:         true,
			expectedNetworkPoliciesBackedUp: true,
		},
		{
			name: "should skip networkpolicies.networking.k8s.io if we've seen networkpolicies.extensions",
			resourceIncludesExcludes:        collections.NewIncludesExcludes().Includes("*"),
			resourceGV:                      "networking.k8s.io/v1",
			resourceName:                    "networkpolicies",
			resourceNamespaced:              true,
			networkPoliciesBackedUp:         true,
			expectedNetworkPoliciesBackedUp: true,
		},
		{
			name: "list per namespace when not including *",
			resourceIncludesExcludes:  collections.NewIncludesExcludes().Includes("*"),
			resourceGroup:             "apps",
			resourceVersion:           "v1beta1",
			resourceGV:                "apps/v1beta1",
			resourceName:              "deployments",
			resourceNamespaced:        true,
			namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("a", "b"),
			expectedListedNamespaces:  []string{"a", "b"},
			lists: []string{
				`{
	"apiVersion": "apps/v1beta1",
	"kind": "DeploymentList",
	"items": [
		{
			"metadata": {
				"namespace": "a",
				"name": "1"
			}
		}
	]
}`,
				`{
	"apiVersion": "apps/v1beta1v1",
	"kind": "DeploymentList",
	"items": [
		{
			"metadata": {
				"namespace": "b",
				"name": "2"
			}
		}
	]
}`,
			},
			expectedDeploymentsBackedUp: true,
		},
		{
			name: "list all namespaces when including *",
			resourceIncludesExcludes:  collections.NewIncludesExcludes().Includes("*"),
			resourceGroup:             "networking.k8s.io",
			resourceVersion:           "v1",
			resourceGV:                "networking.k8s.io/v1",
			resourceName:              "networkpolicies",
			resourceNamespaced:        true,
			namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("*"),
			expectedListedNamespaces:  []string{""},
			lists: []string{
				`{
	"apiVersion": "networking.k8s.io/v1",
	"kind": "NetworkPolicyList",
	"items": [
		{
			"metadata": {
				"namespace": "a",
				"name": "1"
			}
		}
	]
}`,
			},
			expectedNetworkPoliciesBackedUp: true,
		},
		{
			name: "list all namespaces when cluster-scoped, even with namespace includes",
			resourceIncludesExcludes:  collections.NewIncludesExcludes().Includes("*"),
			resourceGroup:             "certificates.k8s.io",
			resourceVersion:           "v1beta1",
			resourceGV:                "certificates.k8s.io/v1beta1",
			resourceName:              "certificatesigningrequests",
			resourceNamespaced:        false,
			namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("a"),
			expectedListedNamespaces:  []string{""},
			labelSelector:             "a=b",
			lists: []string{
				`{
	"apiVersion": "certifiaces.k8s.io/v1beta1",
	"kind": "CertificateSigningRequestList",
	"items": [
		{
			"metadata": {
				"name": "1",
				"labels": {
					"a": "b"
				}
			}
		}
	]
}`,
			},
		},
		{
			name: "use a custom action",
			resourceIncludesExcludes:  collections.NewIncludesExcludes().Includes("*"),
			resourceGroup:             "certificates.k8s.io",
			resourceVersion:           "v1beta1",
			resourceGV:                "certificates.k8s.io/v1beta1",
			resourceName:              "certificatesigningrequests",
			resourceNamespaced:        false,
			namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("a"),
			expectedListedNamespaces:  []string{""},
			labelSelector:             "a=b",
			lists: []string{
				`{
	"apiVersion": "certifiaces.k8s.io/v1beta1",
	"kind": "CertificateSigningRequestList",
	"items": [
		{
			"metadata": {
				"name": "1",
				"labels": {
					"a": "b"
				}
			}
		}
	]
}`,
			},
			actions: map[string]Action{
				"certificatesigningrequests": &fakeAction{},
				"other": &fakeAction{},
			},
			expectedActionIDs: map[string][]string{
				"certificatesigningrequests": {"1"},
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			var labelSelector *metav1.LabelSelector
			if test.labelSelector != "" {
				s, err := metav1.ParseToLabelSelector(test.labelSelector)
				require.NoError(t, err)
				labelSelector = s
			}
			ctx := &backupContext{
				backup: &v1.Backup{
					Spec: v1.BackupSpec{
						LabelSelector: labelSelector,
					},
				},
				resourceIncludesExcludes:  test.resourceIncludesExcludes,
				namespaceIncludesExcludes: test.namespaceIncludesExcludes,
				deploymentsBackedUp:       test.deploymentsBackedUp,
				networkPoliciesBackedUp:   test.networkPoliciesBackedUp,
			}

			group := &metav1.APIResourceList{
				GroupVersion: test.resourceGV,
			}

			resource := metav1.APIResource{Name: test.resourceName, Namespaced: test.resourceNamespaced}

			itemBackupper := &fakeItemBackupper{}

			var actualActionIDs map[string][]string

			dynamicFactory := &FakeDynamicFactory{}
			gvr := schema.GroupVersionResource{Group: test.resourceGroup, Version: test.resourceVersion}
			gr := schema.GroupResource{Group: test.resourceGroup, Resource: test.resourceName}
			for i, namespace := range test.expectedListedNamespaces {
				obj := toRuntimeObject(t, test.lists[i])

				client := &FakeDynamicClient{}
				client.On("List", metav1.ListOptions{LabelSelector: test.labelSelector}).Return(obj, nil)
				dynamicFactory.On("ClientForGroupVersionResource", gvr, resource, namespace).Return(client, nil)

				action := test.actions[test.resourceName]

				list, err := meta.ExtractList(obj)
				require.NoError(t, err)
				for i := range list {
					item := list[i].(*unstructured.Unstructured)
					itemBackupper.On("backupItem", ctx, item.Object, gr.String(), action).Return(nil)
					if action != nil {
						a, err := meta.Accessor(item)
						require.NoError(t, err)
						ns := a.GetNamespace()
						name := a.GetName()
						id := ns
						if id != "" {
							id += "/"
						}
						id += name
						if actualActionIDs == nil {
							actualActionIDs = make(map[string][]string)
						}
						actualActionIDs[test.resourceName] = append(actualActionIDs[test.resourceName], id)
					}
				}
			}

			discoveryHelper := &fakeDiscoveryHelper{
				mapper: &FakeMapper{
					Resources: map[schema.GroupVersionResource]schema.GroupVersionResource{
						schema.GroupVersionResource{Resource: "certificatesigningrequests"}: schema.GroupVersionResource{Group: "certificates.k8s.io", Version: "v1beta1", Resource: "certificatesigningrequests"},
						schema.GroupVersionResource{Resource: "other"}:                      schema.GroupVersionResource{Group: "somegroup", Version: "someversion", Resource: "otherthings"},
					},
				},
			}

			kb, err := NewKubernetesBackupper(discoveryHelper, dynamicFactory, test.actions)
			require.NoError(t, err)
			backupper := kb.(*kubernetesBackupper)
			backupper.itemBackupper = itemBackupper

			err = backupper.backupResource(ctx, group, resource)

			assert.Equal(t, test.expectedDeploymentsBackedUp, ctx.deploymentsBackedUp)
			assert.Equal(t, test.expectedNetworkPoliciesBackedUp, ctx.networkPoliciesBackedUp)
			assert.Equal(t, test.expectedActionIDs, actualActionIDs)
		})
	}
}

type fakeItemBackupper struct {
	mock.Mock
}

func (f *fakeItemBackupper) backupItem(ctx *backupContext, obj map[string]interface{}, groupResource string, action Action) error {
	args := f.Called(ctx, obj, groupResource, action)
	return args.Error(0)
}

type fakeTarWriter struct {
	closeCalled      bool
	headers          []*tar.Header
	data             [][]byte
	writeHeaderError error
	writeError       error
}

func (w *fakeTarWriter) Close() error { return nil }

func (w *fakeTarWriter) Write(data []byte) (int, error) {
	w.data = append(w.data, data)
	return 0, w.writeError
}

func (w *fakeTarWriter) WriteHeader(header *tar.Header) error {
	w.headers = append(w.headers, header)
	return w.writeHeaderError
}

func TestBackupItem(t *testing.T) {
	tests := []struct {
		name                      string
		item                      string
		namespaceIncludesExcludes *collections.IncludesExcludes
		expectError               bool
		expectExcluded            bool
		expectedTarHeaderName     string
		tarWriteError             bool
		tarHeaderWriteError       bool
		customAction              bool
		expectedActionID          string
	}{
		{
			name:        "empty map",
			item:        "{}",
			expectError: true,
		},
		{
			name:        "missing name",
			item:        `{"metadata":{}}`,
			expectError: true,
		},
		{
			name: "excluded by namespace",
			item: `{"metadata":{"namespace":"foo","name":"bar"}}`,
			namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("*").Excludes("foo"),
			expectError:               false,
			expectExcluded:            true,
		},
		{
			name: "explicit namespace include",
			item: `{"metadata":{"namespace":"foo","name":"bar"}}`,
			namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("foo"),
			expectError:               false,
			expectExcluded:            false,
			expectedTarHeaderName:     "namespaces/foo/resource.group/bar.json",
		},
		{
			name: "* namespace include",
			item: `{"metadata":{"namespace":"foo","name":"bar"}}`,
			namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("*"),
			expectError:               false,
			expectExcluded:            false,
			expectedTarHeaderName:     "namespaces/foo/resource.group/bar.json",
		},
		{
			name:                  "cluster-scoped",
			item:                  `{"metadata":{"name":"bar"}}`,
			expectError:           false,
			expectExcluded:        false,
			expectedTarHeaderName: "cluster/resource.group/bar.json",
		},
		{
			name:                  "make sure status is deleted",
			item:                  `{"metadata":{"name":"bar"},"spec":{"color":"green"},"status":{"foo":"bar"}}`,
			expectError:           false,
			expectExcluded:        false,
			expectedTarHeaderName: "cluster/resource.group/bar.json",
		},
		{
			name:                "tar header write error",
			item:                `{"metadata":{"name":"bar"},"spec":{"color":"green"},"status":{"foo":"bar"}}`,
			expectError:         true,
			tarHeaderWriteError: true,
		},
		{
			name:          "tar write error",
			item:          `{"metadata":{"name":"bar"},"spec":{"color":"green"},"status":{"foo":"bar"}}`,
			expectError:   true,
			tarWriteError: true,
		},
		{
			name: "action invoked - cluster-scoped",
			namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("*"),
			item:                  `{"metadata":{"name":"bar"}}`,
			expectError:           false,
			expectExcluded:        false,
			expectedTarHeaderName: "cluster/resource.group/bar.json",
			customAction:          true,
			expectedActionID:      "bar",
		},
		{
			name: "action invoked - namespaced",
			namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("*"),
			item:                  `{"metadata":{"namespace": "myns", "name":"bar"}}`,
			expectError:           false,
			expectExcluded:        false,
			expectedTarHeaderName: "namespaces/myns/resource.group/bar.json",
			customAction:          true,
			expectedActionID:      "myns/bar",
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			item, err := getAsMap(test.item)
			if err != nil {
				t.Fatal(err)
			}

			namespaces := test.namespaceIncludesExcludes
			if namespaces == nil {
				namespaces = collections.NewIncludesExcludes()
			}

			w := &fakeTarWriter{}
			if test.tarHeaderWriteError {
				w.writeHeaderError = errors.New("error")
			}
			if test.tarWriteError {
				w.writeError = errors.New("error")
			}

			var (
				actionParam Action
				action      *fakeAction
				backup      *v1.Backup
			)
			if test.customAction {
				action = &fakeAction{}
				actionParam = action
				backup = &v1.Backup{}
			}
			ctx := &backupContext{
				backup: backup,
				namespaceIncludesExcludes: namespaces,
				w: w,
			}
			b := &realItemBackupper{}
			err = b.backupItem(ctx, item, "resource.group", actionParam)
			gotError := err != nil
			if e, a := test.expectError, gotError; e != a {
				t.Fatalf("error: expected %t, got %t", e, a)
			}
			if test.expectError {
				return
			}

			if test.expectExcluded {
				if len(w.headers) > 0 {
					t.Errorf("unexpected header write")
				}
				if len(w.data) > 0 {
					t.Errorf("unexpected data write")
				}
				return
			}

			// we have to delete status as that's what backupItem does,
			// and this ensures that we're verifying the right data
			delete(item, "status")
			itemWithoutStatus, err := json.Marshal(&item)
			if err != nil {
				t.Fatal(err)
			}

			if e, a := 1, len(w.headers); e != a {
				t.Errorf("headers: expected %d, got %d", e, a)
			}

			if e, a := test.expectedTarHeaderName, w.headers[0].Name; e != a {
				t.Errorf("header.name: expected %s, got %s", e, a)
			}

			if e, a := int64(len(itemWithoutStatus)), w.headers[0].Size; e != a {
				t.Errorf("header.size: expected %d, got %d", e, a)
			}

			if e, a := byte(tar.TypeReg), w.headers[0].Typeflag; e != a {
				t.Errorf("header.typeflag: expected %v, got %v", e, a)
			}

			if e, a := int64(0755), w.headers[0].Mode; e != a {
				t.Errorf("header.mode: expected %d, got %d", e, a)
			}

			if w.headers[0].ModTime.IsZero() {
				t.Errorf("header.modTime: expected it to be set")
			}

			if e, a := 1, len(w.data); e != a {
				t.Errorf("# of data: expected %d, got %d", e, a)
			}

			actual, err := getAsMap(string(w.data[0]))
			if err != nil {
				t.Fatal(err)
			}
			if e, a := item, actual; !reflect.DeepEqual(e, a) {
				t.Errorf("data: expected %s, got %s", e, a)
			}

			if test.customAction {
				if len(action.ids) != 1 {
					t.Errorf("unexpected custom action ids: %v", action.ids)
				} else if e, a := test.expectedActionID, action.ids[0]; e != a {
					t.Errorf("action.ids[0]: expected %s, got %s", e, a)
				}

				if len(action.backups) != 1 {
					t.Errorf("unexpected custom action backups: %#v", action.backups)
				} else if e, a := backup, action.backups[0]; e != a {
					t.Errorf("action.backups[0]: expected %#v, got %#v", e, a)
				}
			}
		})
	}
}

func getAsMap(j string) (map[string]interface{}, error) {
	m := make(map[string]interface{})
	err := json.Unmarshal([]byte(j), &m)
	return m, err
}

func toRuntimeObject(t *testing.T, data string) runtime.Object {
	o, _, err := unstructured.UnstructuredJSONScheme.Decode([]byte(data), nil, nil)
	require.NoError(t, err)
	return o
}
