// Copyright IBM Corp. 2020, 2026
// SPDX-License-Identifier: BUSL-1.1

package auth_test

import (
	"context"
	"fmt"
	"sort"
	"strings"
	"testing"

	"github.com/hashicorp/boundary/api/authmethods"
	"github.com/hashicorp/boundary/api/authtokens"
	"github.com/hashicorp/boundary/api/roles"
	"github.com/hashicorp/boundary/globals"
	"github.com/hashicorp/boundary/internal/auth/password"
	"github.com/hashicorp/boundary/internal/authtoken"
	"github.com/hashicorp/boundary/internal/daemon/controller"
	"github.com/hashicorp/boundary/internal/daemon/controller/auth"
	authmethodsservice "github.com/hashicorp/boundary/internal/daemon/controller/handlers/authmethods"
	authpb "github.com/hashicorp/boundary/internal/gen/controller/auth"
	"github.com/hashicorp/boundary/internal/iam"
	"github.com/hashicorp/boundary/internal/server"
	"github.com/hashicorp/boundary/internal/types/action"
	"github.com/hashicorp/boundary/internal/types/resource"
	"github.com/hashicorp/boundary/internal/types/scope"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestFetchActionSetForId_orgRole(t *testing.T) {
	tc := controller.NewTestController(t, nil)
	defer tc.Shutdown()

	conn := tc.DbConn()
	client := tc.Client()
	token := tc.Token()
	client.SetToken(token.Token)
	org, _ := iam.TestScopes(t, tc.IamRepo(), iam.WithUserId(token.UserId), iam.WithSkipAdminRoleCreation(true), iam.WithSkipDefaultRoleCreation(true))

	iamRepoFn := func() (*iam.Repository, error) {
		return tc.IamRepo(), nil
	}
	serversRepoFn := func() (*server.Repository, error) {
		return tc.ServersRepo(), nil
	}
	authTokenRepoFn := func() (*authtoken.Repository, error) {
		return tc.AuthTokenRepo(), nil
	}

	// Delete the global default role so it doesn't interfere with the
	// permissions we're testing here
	rolesClient := roles.NewClient(tc.Client())
	rolesResp, err := rolesClient.List(tc.Context(), scope.Global.String())
	require.NoError(t, err)
	require.NotNil(t, rolesResp)
	assert.Len(t, rolesResp.GetItems(), 3)
	var adminRoleId string
	for _, item := range rolesResp.GetItems() {
		if strings.Contains(item.Name, "Authenticated User") ||
			strings.Contains(item.Name, "Login") {
			_, err := rolesClient.Delete(tc.Context(), item.Id)
			require.NoError(t, err)
		} else {
			adminRoleId = item.Id
		}
	}
	_, err = rolesClient.Delete(tc.Context(), adminRoleId)
	require.NoError(t, err)

	orgRole := iam.TestRole(t, conn, org.GetPublicId())
	iam.TestUserRole(t, conn, orgRole.PublicId, token.UserId)
	iam.TestRoleGrant(t, conn, orgRole.PublicId, "ids=ttcp_foo;actions=read,update")
	iam.TestRoleGrant(t, conn, orgRole.PublicId, "ids=ttcp_bar;actions=read,update,delete,authorize-session")
	iam.TestRoleGrant(t, conn, orgRole.PublicId, "ids=*;type=policy;actions=add-grants,remove-grants")

	cases := []struct {
		name         string
		id           string
		avail        action.ActionSet
		allowed      action.ActionSet
		typeOverride resource.Type
	}{
		{
			name: "base",
		},
		{
			name:  "no match",
			id:    "zip",
			avail: action.NewActionSet(action.Read, action.Update),
		},
		{
			name:    "disjoint match",
			id:      "ttcp_bar",
			avail:   action.NewActionSet(action.Delete, action.AddGrants, action.Read, action.RemoveHostSets),
			allowed: action.NewActionSet(action.Delete, action.Read),
		},
		{
			name:         "different type",
			id:           "anything",
			typeOverride: resource.Scope,
		},
		{
			name:         "type match",
			id:           "anything",
			typeOverride: resource.Policy,
			avail:        action.NewActionSet(action.Read, action.AddGrants),
			allowed:      action.NewActionSet(action.AddGrants),
		},
	}
	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			req := require.New(t)
			ctx := auth.NewVerifierContext(
				context.Background(),
				iamRepoFn,
				authTokenRepoFn,
				serversRepoFn,
				tc.Kms(),
				&authpb.RequestInfo{
					PublicId:       token.Id,
					EncryptedToken: strings.Split(token.Token, "_")[2],
					TokenFormat:    uint32(auth.AuthTokenTypeBearer),
				})
			typ := resource.Role
			if tt.typeOverride != resource.Unknown {
				typ = tt.typeOverride
			}
			res := auth.Verify(ctx, typ, []auth.Option{
				auth.WithId("ttcp_foo"),
				auth.WithAction(action.Read),
				auth.WithScopeId(org.PublicId),
			}...)
			req.NoError(res.Error)
			assert.Equal(t, tt.allowed, res.FetchActionSetForId(ctx, tt.id, tt.avail))
		})
	}
}

func TestFetchActionSetForId_projRole(t *testing.T) {
	tc := controller.NewTestController(t, nil)
	defer tc.Shutdown()

	conn := tc.DbConn()
	client := tc.Client()
	token := tc.Token()
	client.SetToken(token.Token)
	_, proj := iam.TestScopes(t, tc.IamRepo(), iam.WithUserId(token.UserId), iam.WithSkipAdminRoleCreation(true), iam.WithSkipDefaultRoleCreation(true))

	iamRepoFn := func() (*iam.Repository, error) {
		return tc.IamRepo(), nil
	}
	serversRepoFn := func() (*server.Repository, error) {
		return tc.ServersRepo(), nil
	}
	authTokenRepoFn := func() (*authtoken.Repository, error) {
		return tc.AuthTokenRepo(), nil
	}

	// Delete the global default role so it doesn't interfere with the
	// permissions we're testing here
	rolesClient := roles.NewClient(tc.Client())
	rolesResp, err := rolesClient.List(tc.Context(), scope.Global.String())
	require.NoError(t, err)
	require.NotNil(t, rolesResp)
	assert.Len(t, rolesResp.GetItems(), 3)
	var adminRoleId string
	for _, item := range rolesResp.GetItems() {
		if strings.Contains(item.Name, "Authenticated User") ||
			strings.Contains(item.Name, "Login") {
			_, err := rolesClient.Delete(tc.Context(), item.Id)
			require.NoError(t, err)
		} else {
			adminRoleId = item.Id
		}
	}
	_, err = rolesClient.Delete(tc.Context(), adminRoleId)
	require.NoError(t, err)

	projRole := iam.TestRole(t, conn, proj.GetPublicId())
	iam.TestUserRole(t, conn, projRole.PublicId, token.UserId)
	iam.TestRoleGrant(t, conn, projRole.PublicId, "ids=ttcp_foo;actions=read,update")
	iam.TestRoleGrant(t, conn, projRole.PublicId, "ids=ttcp_bar;actions=read,update,delete,authorize-session")
	iam.TestRoleGrant(t, conn, projRole.PublicId, "ids=*;type=role;actions=add-grants,remove-grants")

	cases := []struct {
		name         string
		id           string
		avail        action.ActionSet
		allowed      action.ActionSet
		typeOverride resource.Type
	}{
		{
			name: "base",
		},
		{
			name:  "no match",
			id:    "zip",
			avail: action.NewActionSet(action.Read, action.Update),
		},
		{
			name:    "disjoint match",
			id:      "ttcp_bar",
			avail:   action.NewActionSet(action.Delete, action.AddGrants, action.Read, action.RemoveHostSets),
			allowed: action.NewActionSet(action.Delete, action.Read),
		},
		{
			name:         "different type",
			id:           "anything",
			typeOverride: resource.Scope,
		},
		{
			name:         "type match",
			id:           "anything",
			typeOverride: resource.Role,
			avail:        action.NewActionSet(action.Read, action.AddGrants),
			allowed:      action.NewActionSet(action.AddGrants),
		},
	}
	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			req := require.New(t)
			ctx := auth.NewVerifierContext(
				context.Background(),
				iamRepoFn,
				authTokenRepoFn,
				serversRepoFn,
				tc.Kms(),
				&authpb.RequestInfo{
					PublicId:       token.Id,
					EncryptedToken: strings.Split(token.Token, "_")[2],
					TokenFormat:    uint32(auth.AuthTokenTypeBearer),
				})
			typ := resource.Target
			if tt.typeOverride != resource.Unknown {
				typ = tt.typeOverride
			}
			res := auth.Verify(ctx, typ, []auth.Option{
				auth.WithId("ttcp_foo"),
				auth.WithAction(action.Read),
				auth.WithScopeId(proj.PublicId),
			}...)
			req.NoError(res.Error)
			assert.Equal(t, tt.allowed, res.FetchActionSetForId(ctx, tt.id, tt.avail))
		})
	}
}

func TestRecursiveListingDifferentOutputFields(t *testing.T) {
	require, assert := require.New(t), assert.New(t)
	tc := controller.NewTestController(t, &controller.TestControllerOpts{
		// Disable this to avoid having to deal with sorting them in the test
		DisableOidcAuthMethodCreation: true,
		DisableLdapAuthMethodCreation: true,
	})
	defer tc.Shutdown()

	conn := tc.DbConn()
	client := tc.Client()
	token := tc.Token()
	client.SetToken(token.Token)

	// Delete the global default role so it doesn't interfere with the
	// permissions we're testing here
	rolesClient := roles.NewClient(tc.Client())
	rolesResp, err := rolesClient.List(tc.Context(), scope.Global.String())
	require.NoError(err)
	require.NotNil(rolesResp)
	assert.Len(rolesResp.GetItems(), 3)
	for _, item := range rolesResp.GetItems() {
		if strings.Contains(item.Name, "Authenticated User") ||
			strings.Contains(item.Name, "Login") {
			_, err := rolesClient.Delete(tc.Context(), item.Id)
			require.NoError(err)
		}
	}

	// Set some global permissions so we can read the auth method there. Here we
	// will expect the defaults.
	globalRole := iam.TestRole(t, conn, scope.Global.String())
	iam.TestUserRole(t, conn, globalRole.PublicId, token.UserId)
	iam.TestUserRole(t, conn, globalRole.PublicId, globals.AnonymousUserId)
	iam.TestRoleGrant(t, conn, globalRole.PublicId, "ids=*;type=auth-method;actions=list,no-op")

	// Create some users at the org level, and some role grants for them
	org, _ := iam.TestScopes(t, tc.IamRepo(), iam.WithUserId(token.UserId), iam.WithSkipAdminRoleCreation(true), iam.WithSkipDefaultRoleCreation(true))
	orgAm1 := password.TestAuthMethod(t, conn, org.GetPublicId(), password.WithName("orgam1"), password.WithDescription("orgam1"))
	orgAm2 := password.TestAuthMethod(t, conn, org.GetPublicId(), password.WithName("orgam2"), password.WithDescription("orgam2"))
	orgRole := iam.TestRole(t, conn, org.GetPublicId())
	iam.TestUserRole(t, conn, orgRole.PublicId, token.UserId)
	// The first and second will actually not take effect for output
	// grants because it's a list and output fields are scoped by action. So we
	// expect only name and scope_id for the auth methods in the scope, using
	// two patterns below. However, since you need an action on the resource for
	// list to return anything, those grants allow us to list the items, while
	// also verifying that those output fields don't take effect for the wrong
	// action.
	iam.TestRoleGrant(t, conn, orgRole.PublicId, fmt.Sprintf("ids=%s;actions=read;output_fields=id,version", orgAm1.GetPublicId()))
	iam.TestRoleGrant(t, conn, orgRole.PublicId, fmt.Sprintf("ids=%s;actions=read;output_fields=description", orgAm2.GetPublicId()))
	iam.TestRoleGrant(t, conn, orgRole.PublicId, "ids=*;type=auth-method;output_fields=name")
	iam.TestRoleGrant(t, conn, orgRole.PublicId, "ids=*;type=auth-method;actions=list;output_fields=scope_id")

	amClient := authmethods.NewClient(tc.Client())
	resp, err := amClient.List(tc.Context(), scope.Global.String(), authmethods.WithRecursive(true))
	// Basic sanity checks
	require.NoError(err)
	require.NotNil(resp)
	require.NotNil(resp.GetItems())
	assert.Len(resp.GetItems(), 3)
	items := resp.GetResponse().Map["items"].([]any)
	require.NotNil(items)

	// The default generated roles don't have output field definitions for them,
	// so we look for "scope" and if it's there we skip. Otherwise, we look for
	// "version" and if found expect that we will see a global auth method also
	// containing name and description. Otherwise it's an org role and we expect
	// to see name and scope_id. At the end we verify we've seen the exact
	// numbers of each we expect. Note that we expect 3 at global because of the
	// auto generated one.
	var globalAms, orgAms int
	for _, item := range items {
		m := item.(map[string]any)
		require.NotNil(m)
		switch m["scope_id"].(string) {
		case scope.Global.String():
			// Validate that it contains fields anon shouldn't; we'll check anon
			// later
			assert.Contains(m, "created_time")
			assert.Contains(m, "attributes")
			globalAms++

		default:
			assert.Len(m, 2)
			assert.Contains(m, "name")
			assert.Contains(m, "scope_id")
			orgAms++
		}
	}
	assert.Equal(1, globalAms)
	assert.Equal(2, orgAms)

	// Now act as the anonymous user and ensure that the fields we checked for
	// before are not available
	tc.Client().SetToken("")
	amClient = authmethods.NewClient(tc.Client())
	resp, err = amClient.List(tc.Context(), scope.Global.String(), authmethods.WithRecursive(true))
	require.NoError(err)
	require.NotNil(resp)
	require.NotNil(resp.GetItems())
	assert.Len(resp.GetItems(), 1)
	item := resp.GetResponse().Map["items"].([]any)[0].(map[string]any)
	assert.NotContains(item, "created_time")
	assert.NotContains(item, "attributes")
}

func TestSelfReadingDifferentOutputFields(t *testing.T) {
	tc := controller.NewTestController(t, nil)
	defer tc.Shutdown()

	conn := tc.DbConn()

	s, err := authmethodsservice.NewService(
		tc.Context(),
		tc.Kms(),
		tc.Controller().PasswordAuthRepoFn,
		tc.Controller().OidcRepoFn,
		tc.Controller().IamRepoFn,
		tc.Controller().AuthTokenRepoFn,
		tc.Controller().LdapRepoFn,
		tc.Controller().AuthMethodRepoFn,
		1000,
	)
	require.NoError(t, err)

	// Create two auth tokens belonging to different users in the org. Each will
	// have grants below and should be able to read the other token but see more
	// fields for its own.
	org, _ := iam.TestScopes(t, tc.IamRepo(), iam.WithSkipAdminRoleCreation(true), iam.WithSkipDefaultRoleCreation(true))
	am := password.TestAuthMethod(t, conn, org.GetPublicId())
	acct1 := password.TestAccount(t, conn, am.GetPublicId(), "acct1")
	acct2 := password.TestAccount(t, conn, am.GetPublicId(), "acct2")
	user1 := iam.TestUser(t, tc.IamRepo(), org.GetPublicId(), iam.WithAccountIds(acct1.GetPublicId()))
	user2 := iam.TestUser(t, tc.IamRepo(), org.GetPublicId(), iam.WithAccountIds(acct2.GetPublicId()))
	at1, err := tc.AuthTokenRepo().CreateAuthToken(tc.Context(), user1, acct1.GetPublicId())
	require.NoError(t, err)
	token1, err := s.ConvertInternalAuthTokenToApiAuthToken(tc.Context(), at1)
	require.NoError(t, err)
	at2, err := tc.AuthTokenRepo().CreateAuthToken(tc.Context(), user2, acct2.GetPublicId())
	require.NoError(t, err)
	token2, err := s.ConvertInternalAuthTokenToApiAuthToken(tc.Context(), at2)
	require.NoError(t, err)

	orgRole := iam.TestRole(t, conn, org.GetPublicId())
	iam.TestUserRole(t, conn, orgRole.PublicId, user1.GetPublicId())
	iam.TestUserRole(t, conn, orgRole.PublicId, user2.GetPublicId())
	iam.TestRoleGrant(t, conn, orgRole.PublicId, "ids=*;type=auth-token;actions=read:self;output_fields=account_id")
	iam.TestRoleGrant(t, conn, orgRole.PublicId, "ids=*;type=auth-token;actions=read;output_fields=id,scope_id")

	cases := []struct {
		name     string
		token    string
		lookupId string
		keys     []string
	}{
		{
			name:     "at1 self",
			token:    token1.GetToken(),
			lookupId: token1.GetId(),
			keys:     []string{"id", "scope_id", "account_id"},
		},
		{
			name:     "at2 self",
			token:    token2.GetToken(),
			lookupId: token2.GetId(),
			keys:     []string{"id", "scope_id", "account_id"},
		},
		{
			name:     "at1 other",
			token:    token1.GetToken(),
			lookupId: token2.GetId(),
			keys:     []string{"id", "scope_id"},
		},
		{
			name:     "at2 other",
			token:    token2.GetToken(),
			lookupId: token1.GetId(),
			keys:     []string{"id", "scope_id"},
		},
	}
	for _, test := range cases {
		t.Run(test.name, func(t *testing.T) {
			assert, require := assert.New(t), require.New(t)
			client := tc.Client().Clone()
			client.SetToken(test.token)
			atClient := authtokens.NewClient(client)
			resp, err := atClient.Read(tc.Context(), test.lookupId)
			require.NoError(err)
			require.NotNil(resp)
			require.NotNil(resp.GetItem())
			item := resp.GetResponse().Map
			require.NotNil(item)
			keys := make([]string, 0, len(item))
			for k := range item {
				keys = append(keys, k)
			}
			sort.Strings(test.keys)
			sort.Strings(keys)
			assert.Equal(test.keys, keys)
		})
	}
}
