NET-5600/container-test-acl-enabled (#18887)

* feat: add container tests for resource http api with acl enabled

* refactor: clean up
This commit is contained in:
Poonam Jadhav 2023-10-03 10:55:31 -04:00 committed by GitHub
parent 9addd9ed7c
commit 6c92dd1359
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 757 additions and 0 deletions

View File

@ -805,6 +805,7 @@ func NewHttpClient(transport *http.Transport, tlsConf api.TLSConfig) (*http.Clie
}
// request is used to help build up a request
// defined a custom object that includes use-case specific config
type request struct {
config *api.Config
method string

View File

@ -45,6 +45,7 @@ type Agent interface {
Exec(ctx context.Context, cmd []string) (string, error)
DataDir() string
GetGRPCConn() *grpc.ClientConn
GetAPIClientConfig() api.Config
}
// Config is a set of configurations required to create a Agent

View File

@ -68,6 +68,8 @@ type consulContainerNode struct {
nextConnectPortOffset int
info AgentInfo
apiClientConfig api.Config
}
func (c *consulContainerNode) GetPod() testcontainers.Container {
@ -330,6 +332,7 @@ func NewConsulContainer(ctx context.Context, config Config, cluster *Cluster, po
}
node.client = apiClient
node.apiClientConfig = *apiConfig
node.clientAddr = clientAddr
node.clientCACertFile = clientCACertFile
}
@ -445,6 +448,10 @@ func (c *consulContainerNode) GetIP() string {
return c.ip
}
func (c *consulContainerNode) GetAPIClientConfig() api.Config {
return c.apiClientConfig
}
func (c *consulContainerNode) RegisterTermination(f func() error) {
c.terminateFuncs = append(c.terminateFuncs, f)
}

View File

@ -0,0 +1,223 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package resource
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_ServerAgent_Endpoints(t *testing.T) {
t.Parallel()
numOfServers := 1
numOfClients := 0
cluster, resourceClient := SetupClusterAndClient(t, makeClusterConfig(numOfServers, numOfClients, true), true)
resource := Resource{
HttpClient: resourceClient,
}
defer Terminate(t, cluster)
testCases := []testCase{
{
description: "should write resource successfully when token is provided",
operations: []operation{
{
action: applyResource,
expectedErrorMsg: "",
includeToken: true,
},
{
action: readResource,
expectedErrorMsg: "",
includeToken: true,
},
{
action: listResource,
expectedErrorMsg: "",
includeToken: true,
},
{
action: deleteResource,
expectedErrorMsg: "",
includeToken: true,
},
},
config: []config{
{
gvk: demoGVK,
resourceName: "korn",
queryOptions: defaultTenancyQueryOptions,
payload: demoPayload,
},
{
gvk: demoGVK,
resourceName: "korn",
queryOptions: defaultTenancyQueryOptions,
},
{
gvk: demoGVK,
queryOptions: defaultTenancyQueryOptions,
},
{
gvk: demoGVK,
resourceName: "korn",
queryOptions: defaultTenancyQueryOptions,
},
},
},
{
description: "should return permission denied",
operations: []operation{
{
action: applyResource,
expectedErrorMsg: "Unexpected response code: 403 (rpc error: code = PermissionDenied desc = Permission denied",
includeToken: false,
},
{
action: applyResource,
expectedErrorMsg: "",
includeToken: true,
},
{
action: readResource,
expectedErrorMsg: "Unexpected response code: 403 (rpc error: code = PermissionDenied desc = Permission denied",
includeToken: false,
},
{
action: listResource,
expectedErrorMsg: "Unexpected response code: 403 (rpc error: code = PermissionDenied desc = Permission denied",
includeToken: false,
},
{
action: deleteResource,
expectedErrorMsg: "Unexpected response code: 403 (rpc error: code = PermissionDenied desc = Permission denied",
includeToken: false,
},
},
config: []config{
{
gvk: demoGVK,
resourceName: "prince",
queryOptions: defaultTenancyQueryOptions,
payload: demoPayload,
},
{
gvk: demoGVK,
resourceName: "deleteme",
queryOptions: defaultTenancyQueryOptions,
payload: demoPayload,
},
{
gvk: demoGVK,
resourceName: "keith",
queryOptions: defaultTenancyQueryOptions,
},
{
gvk: demoGVK,
queryOptions: defaultTenancyQueryOptions,
},
{
gvk: demoGVK,
resourceName: "deleteme",
queryOptions: defaultTenancyQueryOptions,
},
},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.description, func(t *testing.T) {
for i, op := range tc.operations {
if op.includeToken {
tc.config[i].queryOptions.Token = cluster.TokenBootstrap
}
err := op.action(&resource, tc.config[i])
if len(op.expectedErrorMsg) > 0 {
require.Error(t, err)
require.Contains(t, err.Error(), op.expectedErrorMsg)
} else {
require.NoError(t, err)
}
}
})
}
}
func Test_ClientAgent(t *testing.T) {
t.Parallel()
numOfServers := 1
numOfClients := 1
cluster, resourceClient := SetupClusterAndClient(t, makeClusterConfig(numOfServers, numOfClients, true), false)
resource := Resource{
HttpClient: resourceClient,
}
defer Terminate(t, cluster)
testCases := []testCase{
{
description: "should write resource successfully when token is provided",
operations: []operation{
{
action: applyResource,
expectedErrorMsg: "",
includeToken: true,
},
},
config: []config{
{
gvk: demoGVK,
resourceName: "test",
queryOptions: defaultTenancyQueryOptions,
payload: demoPayload,
},
},
},
{
description: "should return unauthorized when token is bad",
operations: []operation{
{
action: applyResource,
expectedErrorMsg: "Unexpected response code: 403 (rpc error: code = PermissionDenied desc = Permission denied",
includeToken: false,
},
},
config: []config{
{
gvk: demoGVK,
resourceName: "test2",
queryOptions: defaultTenancyQueryOptions,
payload: demoPayload,
},
},
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.description, func(t *testing.T) {
for i, op := range tc.operations {
if op.includeToken {
tc.config[i].queryOptions.Token = cluster.TokenBootstrap
}
err := op.action(&resource, tc.config[i])
if len(op.expectedErrorMsg) > 0 {
require.Error(t, err)
require.Contains(t, err.Error(), op.expectedErrorMsg)
} else {
require.NoError(t, err)
}
}
})
}
}

View File

@ -0,0 +1,312 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/hashicorp/consul/api"
)
// QueryOptions are used to parameterize a query
type QueryOptions struct {
// Namespace overrides the `default` namespace
// Note: Namespaces are available only in Consul Enterprise
Namespace string
// Partition overrides the `default` partition
// Note: Partitions are available only in Consul Enterprise
Partition string
// Providing a peer name in the query option
Peer string
// RequireConsistent forces the read to be fully consistent.
// This is more expensive but prevents ever performing a stale
// read.
RequireConsistent bool
// ctx is an optional context pass through to the underlying HTTP
// request layer. Use Context() and WithContext() to manage this.
ctx context.Context
// Token is used to provide a per-request ACL token
// which overrides the agent's default token.
Token string
}
// Client provides a client to the Consul API
type HttpClient struct {
modifyLock sync.RWMutex
headers http.Header
config api.Config
}
// Headers gets the current set of headers used for requests. This returns a
// copy; to modify it call AddHeader or SetHeaders.
func (c *HttpClient) Headers() http.Header {
c.modifyLock.RLock()
defer c.modifyLock.RUnlock()
if c.headers == nil {
return nil
}
ret := make(http.Header)
for k, v := range c.headers {
ret[k] = append(ret[k], v...)
}
return ret
}
// NewClient returns a new client
func NewClient(config *api.Config) (*HttpClient, error) {
// bootstrap the config
defConfig := api.DefaultConfig()
if config.Address == "" {
config.Address = defConfig.Address
}
if config.Scheme == "" {
config.Scheme = defConfig.Scheme
}
if config.Transport == nil {
config.Transport = defConfig.Transport
}
if config.HttpClient == nil {
var err error
config.HttpClient, err = NewHttpClient(config.Transport)
if err != nil {
return nil, err
}
}
return &HttpClient{config: *config, headers: make(http.Header)}, nil
}
// NewHttpClient returns an http client configured with the given Transport and TLS
// config.
func NewHttpClient(transport *http.Transport) (*http.Client, error) {
client := &http.Client{
Transport: transport,
}
return client, nil
}
// request is used to help build up a request
type request struct {
config *api.Config
method string
url *url.URL
params url.Values
body io.Reader
header http.Header
Obj interface{}
ctx context.Context
}
// setQueryOptions is used to annotate the request with
// additional query options
func (r *request) SetQueryOptions(q *QueryOptions) {
if q == nil {
return
}
if q.Namespace != "" {
// For backwards-compatibility with existing tests,
// use the short-hand query param name "ns"
// rather than the alternative long-hand "namespace"
r.params.Set("ns", q.Namespace)
}
if q.Partition != "" {
// For backwards-compatibility with existing tests,
// use the long-hand query param name "partition"
// rather than the alternative short-hand "ap"
r.params.Set("partition", q.Partition)
}
if q.Peer != "" {
r.params.Set("peer", q.Peer)
}
if q.RequireConsistent {
r.params.Set("consistent", "")
}
if q.Token != "" {
r.header.Set("X-Consul-Token", q.Token)
}
r.ctx = q.ctx
}
// toHTTP converts the request to an HTTP request
func (r *request) toHTTP() (*http.Request, error) {
// Encode the query parameters
r.url.RawQuery = r.params.Encode()
// Check if we should encode the body
if r.body == nil && r.Obj != nil {
b, err := encodeBody(r.Obj)
if err != nil {
return nil, err
}
r.body = b
}
// Create the HTTP request
req, err := http.NewRequest(r.method, r.url.RequestURI(), r.body)
if err != nil {
return nil, err
}
// validate that socket communications that do not use the host, detect
// slashes in the host name and replace it with local host.
// this is required since go started validating req.host in 1.20.6 and 1.19.11.
// prior to that they would strip out the slashes for you. They removed that
// behavior and added more strict validation as part of a CVE.
// This issue is being tracked by the Go team:
// https://github.com/golang/go/issues/61431
// If there is a resolution in this issue, we will remove this code.
// In the time being, this is the accepted workaround.
if strings.HasPrefix(r.url.Host, "/") {
r.url.Host = "localhost"
}
req.URL.Host = r.url.Host
req.URL.Scheme = r.url.Scheme
req.Host = r.url.Host
req.Header = r.header
// Content-Type must always be set when a body is present
// See https://github.com/hashicorp/consul/issues/10011
if req.Body != nil && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
// Setup auth
if r.config.HttpAuth != nil {
req.SetBasicAuth(r.config.HttpAuth.Username, r.config.HttpAuth.Password)
}
if r.ctx != nil {
return req.WithContext(r.ctx), nil
}
return req, nil
}
// newRequest is used to create a new request
func (c *HttpClient) NewRequest(method, path string) *request {
r := &request{
config: &c.config,
method: method,
url: &url.URL{
Scheme: c.config.Scheme,
Host: c.config.Address,
Path: c.config.PathPrefix + path,
},
params: make(map[string][]string),
header: c.Headers(),
}
if c.config.Namespace != "" {
r.params.Set("ns", c.config.Namespace)
}
if c.config.Partition != "" {
r.params.Set("partition", c.config.Partition)
}
if c.config.Token != "" {
r.header.Set("X-Consul-Token", r.config.Token)
}
return r
}
// doRequest runs a request with our client
func (c *HttpClient) DoRequest(r *request) (time.Duration, *http.Response, error) {
req, err := r.toHTTP()
if err != nil {
return 0, nil, err
}
start := time.Now()
resp, err := c.config.HttpClient.Do(req)
diff := time.Since(start)
return diff, resp, err
}
// DecodeBody is used to JSON decode a body
func DecodeBody(resp *http.Response, out interface{}) error {
dec := json.NewDecoder(resp.Body)
return dec.Decode(out)
}
// encodeBody is used to encode a request body
func encodeBody(obj interface{}) (io.Reader, error) {
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
if err := enc.Encode(obj); err != nil {
return nil, err
}
return buf, nil
}
// requireOK is used to wrap doRequest and check for a 200
func RequireOK(resp *http.Response) error {
return RequireHttpCodes(resp, 200)
}
// requireHttpCodes checks for the "allowable" http codes for a response
func RequireHttpCodes(resp *http.Response, httpCodes ...int) error {
// if there is an http code that we require, return w no error
for _, httpCode := range httpCodes {
if resp.StatusCode == httpCode {
return nil
}
}
// if we reached here, then none of the http codes in resp matched any that we expected
// so err out
return generateUnexpectedResponseCodeError(resp)
}
// closeResponseBody reads resp.Body until EOF, and then closes it. The read
// is necessary to ensure that the http.Client's underlying RoundTripper is able
// to re-use the TCP connection. See godoc on net/http.Client.Do.
func CloseResponseBody(resp *http.Response) error {
_, _ = io.Copy(io.Discard, resp.Body)
return resp.Body.Close()
}
type StatusError struct {
Code int
Body string
}
func (e StatusError) Error() string {
return fmt.Sprintf("Unexpected response code: %d (%s)", e.Code, e.Body)
}
// generateUnexpectedResponseCodeError consumes the rest of the body, closes
// the body stream and generates an error indicating the status code was
// unexpected.
func generateUnexpectedResponseCodeError(resp *http.Response) error {
var buf bytes.Buffer
io.Copy(&buf, resp.Body)
CloseResponseBody(resp)
trimmed := strings.TrimSpace(buf.String())
return StatusError{Code: resp.StatusCode, Body: trimmed}
}

View File

@ -0,0 +1,213 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package resource
import (
"fmt"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
libcluster "github.com/hashicorp/consul/test/integration/consul-container/libs/cluster"
libtopology "github.com/hashicorp/consul/test/integration/consul-container/libs/topology"
client "github.com/hashicorp/consul/test/integration/consul-container/test/resource/http_api/client"
)
func makeClusterConfig(numOfServers int, numOfClients int, aclEnabled bool) *libtopology.ClusterConfig {
return &libtopology.ClusterConfig{
NumServers: numOfServers,
NumClients: numOfClients,
LogConsumer: &libtopology.TestLogConsumer{},
BuildOpts: &libcluster.BuildOptions{
Datacenter: "dc1",
ACLEnabled: aclEnabled,
},
ApplyDefaultProxySettings: false,
}
}
type Resource struct {
HttpClient *client.HttpClient
}
type GVK struct {
Group string
Version string
Kind string
}
var demoGVK = GVK{
Group: "demo",
Version: "v2",
Kind: "Artist",
}
var defaultTenancyQueryOptions = client.QueryOptions{
Namespace: "default",
Partition: "default",
Peer: "local",
}
type WriteRequest struct {
Metadata map[string]string
Data map[string]any
}
var demoPayload = WriteRequest{
Metadata: map[string]string{
"foo": "bar",
},
Data: map[string]any{
"name": "cool",
},
}
type config struct {
gvk GVK
resourceName string
queryOptions client.QueryOptions
payload WriteRequest
}
type operation struct {
action func(client *Resource, config config) error
expectedErrorMsg string
includeToken bool
}
type testCase struct {
description string
operations []operation
config []config
}
var applyResource = func(resource *Resource, config config) error {
_, err := resource.Apply(&config.gvk, config.resourceName, &config.queryOptions, &config.payload)
return err
}
var readResource = func(resource *Resource, config config) error {
_, err := resource.Read(&config.gvk, config.resourceName, &config.queryOptions)
return err
}
var deleteResource = func(resource *Resource, config config) error {
err := resource.Delete(&config.gvk, config.resourceName, &config.queryOptions)
return err
}
var listResource = func(resource *Resource, config config) error {
_, err := resource.List(&config.gvk, &config.queryOptions)
return err
}
func (resource *Resource) Read(gvk *GVK, resourceName string, q *client.QueryOptions) (map[string]interface{}, error) {
r := resource.HttpClient.NewRequest("GET", strings.ToLower(fmt.Sprintf("/api/%s/%s/%s/%s", gvk.Group, gvk.Version, gvk.Kind, resourceName)))
r.SetQueryOptions(q)
_, resp, err := resource.HttpClient.DoRequest(r)
if err != nil {
return nil, err
}
defer client.CloseResponseBody(resp)
if err := client.RequireOK(resp); err != nil {
return nil, err
}
var out map[string]interface{}
if err := client.DecodeBody(resp, &out); err != nil {
return nil, err
}
return out, nil
}
func (resource *Resource) Delete(gvk *GVK, resourceName string, q *client.QueryOptions) error {
r := resource.HttpClient.NewRequest("DELETE", strings.ToLower(fmt.Sprintf("/api/%s/%s/%s/%s", gvk.Group, gvk.Version, gvk.Kind, resourceName)))
r.SetQueryOptions(q)
_, resp, err := resource.HttpClient.DoRequest(r)
if err != nil {
return err
}
defer client.CloseResponseBody(resp)
if err := client.RequireHttpCodes(resp, http.StatusNoContent); err != nil {
return err
}
return nil
}
func (resource *Resource) Apply(gvk *GVK, resourceName string, q *client.QueryOptions, payload *WriteRequest) (*map[string]interface{}, error) {
url := strings.ToLower(fmt.Sprintf("/api/%s/%s/%s/%s", gvk.Group, gvk.Version, gvk.Kind, resourceName))
r := resource.HttpClient.NewRequest("PUT", url)
r.SetQueryOptions(q)
r.Obj = payload
_, resp, err := resource.HttpClient.DoRequest(r)
if err != nil {
return nil, err
}
defer client.CloseResponseBody(resp)
if err := client.RequireOK(resp); err != nil {
return nil, err
}
var out map[string]interface{}
if err := client.DecodeBody(resp, &out); err != nil {
return nil, err
}
return &out, nil
}
type ListResponse struct {
Resources []map[string]interface{} `json:"resources"`
}
func (resource *Resource) List(gvk *GVK, q *client.QueryOptions) (*ListResponse, error) {
r := resource.HttpClient.NewRequest("GET", strings.ToLower(fmt.Sprintf("/api/%s/%s/%s", gvk.Group, gvk.Version, gvk.Kind)))
r.SetQueryOptions(q)
_, resp, err := resource.HttpClient.DoRequest(r)
if err != nil {
return nil, err
}
defer client.CloseResponseBody(resp)
if err := client.RequireOK(resp); err != nil {
return nil, err
}
var out *ListResponse
if err := client.DecodeBody(resp, &out); err != nil {
return nil, err
}
return out, nil
}
func SetupClusterAndClient(t *testing.T, clusterConfig *libtopology.ClusterConfig, getServerHttpClient bool) (*libcluster.Cluster, *client.HttpClient) {
cluster, _, _ := libtopology.NewCluster(t, clusterConfig)
// create a http api client for resource service
var resourceHttpClient *client.HttpClient
if getServerHttpClient {
apiClientConfig := cluster.Servers()[0].GetAPIClientConfig()
apiClientConfig.Token = ""
resourceClient, err := client.NewClient(&apiClientConfig)
require.NoError(t, err)
resourceHttpClient = resourceClient
} else {
apiClientConfig := cluster.Clients()[0].GetAPIClientConfig()
apiClientConfig.Token = ""
resourceClient, err := client.NewClient(&apiClientConfig)
require.NoError(t, err)
resourceHttpClient = resourceClient
}
return cluster, resourceHttpClient
}
func Terminate(t *testing.T, cluster *libcluster.Cluster) {
err := cluster.Terminate()
require.NoError(t, err)
}