/*
Copyright 2019 The Kubernetes Authors.

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 main

import (
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"
	"unicode/utf8"

	"k8s.io/apimachinery/pkg/util/sets"
)

var cfg GroupsConfig
var rConfig RestrictionsConfig

var groupsPath = flag.String("groups-path", "", "Directory containing groups.yaml files")
var restrictionsPath = flag.String("restrictions-path", "", "Path to the configuration file containing restrictions")

func TestMain(m *testing.M) {
	flag.Parse()
	var err error

	if *restrictionsPath != "" && !filepath.IsAbs(*restrictionsPath) {
		fmt.Printf("restrictions-path \"%s\" must be an absolute path\n", *restrictionsPath)
		os.Exit(1)
	}

	if *restrictionsPath == "" {
		baseDir, err := os.Getwd()
		if err != nil {
			fmt.Printf("Cannot get current working directory: %v\n", err)
			os.Exit(1)
		}
		rPath := filepath.Join(baseDir, defaultRestrictionsFile)
		restrictionsPath = &rPath
	}

	if err := rConfig.Load(*restrictionsPath); err != nil {
		fmt.Printf("Could not load restrictions config: %v\n", err)
		os.Exit(1)
	}

	if *groupsPath != "" && !filepath.IsAbs(*groupsPath) {
		fmt.Printf("groups-path \"%s\" must be an absolute path\n", *groupsPath)
		os.Exit(1)
	}

	if *groupsPath == "" {
		*groupsPath, err = os.Getwd()
		if err != nil {
			fmt.Printf("Cannot get current working directory: %v\n", err)
			os.Exit(1)
		}
	}

	if err := cfg.Load(*groupsPath, &rConfig); err != nil {
		fmt.Printf("Could not load groups config: %v\n", err)
		os.Exit(1)
	}
	os.Exit(m.Run())
}

// TestMergedGroupsConfig tests that readGroupsConfig reads all
// groups.yaml files and the merged config does not contain any duplicates.
//
// It tests that the config is merged by checking that the final
// GroupsConfig contains at least one group that isn't in the
// root groups.yaml file.
func TestMergedGroupsConfig(t *testing.T) {
	var containsMergedConfig bool
	found := sets.String{}
	dups := sets.String{}

	for _, g := range cfg.Groups {
		name := g.Name
		if name == "steering" {
			containsMergedConfig = true
		}

		if found.Has(name) {
			dups.Insert(name)
		}
		found.Insert(name)
	}

	if !containsMergedConfig {
		t.Errorf("Final GroupsConfig does not have merged configs from all groups.yaml files")
	}
	if n := len(dups); n > 0 {
		t.Errorf("%d duplicate groups: %s", n, strings.Join(dups.List(), ", "))
	}
}

// TestStagingEmailLength tests that the number of characters in the
// project name in emails used for staging repos does not exceed 18.
//
// This validation is needed because gcloud allows PROJECT_IDs of length
// between 6 and 30. So after discounting the "k8s-staging" prefix,
// we are left with 18 chars for the project name.
func TestStagingEmailLength(t *testing.T) {
	var errs []error
	for _, g := range cfg.Groups {
		if strings.HasPrefix(g.EmailId, "k8s-infra-staging-") {
			projectName := strings.TrimSuffix(strings.TrimPrefix(g.EmailId, "k8s-infra-staging-"), "@knative.team")

			len := utf8.RuneCountInString(projectName)
			if len > 18 {
				errs = append(errs, fmt.Errorf("Number of characters in project name \"%s\" should not exceed 18; is: %d", projectName, len))
			}
		}
	}

	if len(errs) > 0 {
		for _, err := range errs {
			t.Error(err)
		}
	}
}

// TestDescriptionLength tests that the number of characters in the
// google groups description does not exceed 300.
//
// This validation is needed because gcloud allows apps:description
// with length no greater than 300
func TestDescriptionLength(t *testing.T) {
	var errs []error
	for _, g := range cfg.Groups {
		description := g.Description

		len := utf8.RuneCountInString(description)
		//Ref: https://developers.google.com/admin-sdk/groups-settings/v1/reference/groups
		if len > 300 {
			errs = append(errs,
				fmt.Errorf("Number of characters in description \"%s\" for group name \"%s\" "+
					"should not exceed 300; is: %d", description, g.Name, len))
		}
	}

	if len(errs) > 0 {
		for _, err := range errs {
			t.Error(err)
		}
	}
}

// Enforce conventions for all groups
func TestGroupConventions(t *testing.T) {
	for _, g := range cfg.Groups {
		// groups are easier to reason about if email and name match
		if !(g.EmailId == g.Name+"@knative.team" || g.EmailId == g.Name+"@knative.dev") {
			t.Errorf("group '%s': expected email '%s@knative.dev or %s@knative.team', got '%s'", g.Name, g.Name, g.Name, g.EmailId)
		}
	}
}

// Enforce conventions for all k8s-infra groups
func TestK8sInfraGroupConventions(t *testing.T) {
	for _, g := range cfg.Groups {
		if strings.HasPrefix(g.EmailId, "k8s-infra") {
			// no owners because we want to prevent manual membership changes
			if len(g.Owners) > 0 {
				t.Errorf("group '%s': must have no owners, only members", g.Name)
			}

			// treat files here as source of truth for membership
			reconcileMembers, ok := g.Settings["ReconcileMembers"]
			if !ok || reconcileMembers != "true" {
				t.Errorf("group '%s': must have settings.ReconcileMembers = true", g.Name)
			}
		}
	}
}

// Enforce conventions for groups used by GKE Group-based RBAC
// - there must be a gke-security-groups@ group
// - its members must be k8s-infra-rbac-*@ groups (and vice-versa)
// - all groups involved must have settings.WhoCanViewMembership = ALL_MEMBERS_CAN_VIEW
func TestK8sInfraRBACGroupConventions(t *testing.T) {
	rbacEmails := make(map[string]bool)
	for _, g := range cfg.Groups {
		if strings.HasPrefix(g.EmailId, "k8s-infra-rbac") {
			rbacEmails[g.EmailId] = false
			// this is necessary for group-based rbac to work
			whoCanViewMembership, ok := g.Settings["WhoCanViewMembership"]
			if !ok || whoCanViewMembership != "ALL_MEMBERS_CAN_VIEW" {
				t.Errorf("group '%s': must have settings.WhoCanViewMembership = ALL_MEMBERS_CAN_VIEW", g.Name)
			}
		}
	}
	foundGKEGroup := false
	for _, g := range cfg.Groups {
		if g.EmailId == "gke-security-groups@knative.dev" {
			foundGKEGroup = true
			// this is necessary for group-based rbac to work
			whoCanViewMembership, ok := g.Settings["WhoCanViewMembership"]
			if !ok || whoCanViewMembership != "ALL_MEMBERS_CAN_VIEW" {
				t.Errorf("group '%s': must have settings.WhoCanViewMembership = ALL_MEMBERS_CAN_VIEW", g.Name)
			}
			for _, email := range g.Members {
				if _, ok := rbacEmails[email]; !ok {
					t.Errorf("group '%s': invalid member '%s', must be a k8s-infra-rbac-*@knative.dev group", g.Name, email)
				} else {
					rbacEmails[email] = true
				}
			}
		}
	}
	if !foundGKEGroup {
		t.Errorf("group '%s' is missing", "gke-security-groups@knative.dev")
	}
	for email, found := range rbacEmails {
		if !found {
			t.Errorf("group '%s': must be a member of gke-security-groups@knative.dev", email)
		}
	}
}

// An e-mail address can only show up once within a given group, whether that
// be as a member, manager, or owner
func TestNoDuplicateMembers(t *testing.T) {
	for _, g := range cfg.Groups {
		members := map[string]bool{}
		for _, m := range g.Members {
			if _, ok := members[m]; ok {
				t.Errorf("group '%s' cannot have duplicate member '%s'", g.EmailId, m)
			}
			members[m] = true
		}
		managers := map[string]bool{}
		for _, m := range g.Managers {
			if _, ok := members[m]; ok {
				t.Errorf("group '%s' manager '%s' cannot also be listed as a member", g.EmailId, m)
			}
			if _, ok := managers[m]; ok {
				t.Errorf("group '%s' cannot have duplicate manager '%s'", g.EmailId, m)
			}
			managers[m] = true
		}
		owners := map[string]bool{}
		for _, m := range g.Owners {
			if _, ok := members[m]; ok {
				t.Errorf("group '%s' owner '%s' cannot also be listed as a member", g.EmailId, m)
			}
			if _, ok := managers[m]; ok {
				t.Errorf("group '%s' owner '%s' cannot also be listed as a manager", g.EmailId, m)
			}
			if _, ok := owners[m]; ok {
				t.Errorf("group '%s' cannot have duplicate owner '%s'", g.EmailId, m)
			}
			owners[m] = true
		}
	}
}

// NOTE: make very certain you know what you are doing if you change one
// // of these groups, we don't want to accidentally lock ourselves out
func TestHardcodedGroupsForParanoia(t *testing.T) {
	groups := map[string][]string{
		"kn-infra-gcp-org-admins@knative.dev": {
			"cy@borg.dev",
			"cy@knative.team",
			"hh@cncf.io",
			"hh@knative.team",
			"dprotaso@gmail.com",
			"jeffrey@cncf.io",
			"dkrook@linuxfoundation.org",
			"ksuszyns@redhat.com",
			"mario@kubermatic.com",
		},
	}

	found := make(map[string]bool)

	for _, g := range cfg.Groups {
		if expected, ok := groups[g.EmailId]; ok {
			found[g.EmailId] = true
			sort.Strings(expected)
			actual := make([]string, len(g.Members))
			copy(actual, g.Members)
			sort.Strings(actual)
			if !reflect.DeepEqual(expected, actual) {
				t.Errorf("group '%s': expected members '%v', got '%v'", g.Name, expected, actual)
			}
		}
	}

	for email := range groups {
		if _, ok := found[email]; !ok {
			t.Errorf("group '%s' is missing, should be present", email)
		}
	}
}

// Setting AllowWebPosting should be set for every group which should support
// access to the group not only via gmail but also via web (you can see the list
// and history of threads and also use web interface to operate the group)
// More info:
//
//	https://developers.google.com/admin-sdk/groups-settings/v1/reference/groups#allowWebPosting
func TestGroupsWhichShouldSupportHistory(t *testing.T) {
	groups := map[string]struct{}{
		"wg-leads@knative.team": {},
	}

	found := make(map[string]struct{})

	for _, group := range cfg.Groups {
		emailId := group.EmailId
		found[emailId] = struct{}{}
		if _, ok := groups[emailId]; ok {
			allowedWebPosting, ok := group.Settings["AllowWebPosting"]
			if !ok {
				t.Errorf(
					"group '%s': must have 'settings.allowedWebPosting = true'",
					group.Name,
				)
			} else if allowedWebPosting != "true" {
				t.Errorf(
					"group '%s': must have 'settings.allowedWebPosting = true'"+
						" but have 'settings.allowedWebPosting = %s' instead",
					group.Name,
					allowedWebPosting,
				)
			}
		}
	}

	for email := range groups {
		if _, ok := found[email]; !ok {
			t.Errorf("group '%s' is missing, should be present", email)
		}
	}
}
