mirror of https://github.com/hashicorp/consul
536 lines
14 KiB
Go
536 lines
14 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package uiserver
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hashicorp/go-hclog"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/net/html"
|
|
|
|
"github.com/hashicorp/consul/agent/config"
|
|
"github.com/hashicorp/consul/sdk/testutil"
|
|
)
|
|
|
|
func TestUIServerIndex(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cfg *config.RuntimeConfig
|
|
path string
|
|
tx UIDataTransform
|
|
wantStatus int
|
|
wantContains []string
|
|
wantNotContains []string
|
|
wantEnv map[string]interface{}
|
|
wantUICfgJSON string
|
|
}{
|
|
{
|
|
name: "basic UI serving",
|
|
cfg: basicUIEnabledConfig(),
|
|
path: "/", // Note /index.html redirects to /
|
|
wantStatus: http.StatusOK,
|
|
wantContains: []string{"<!-- CONSUL_VERSION:"},
|
|
wantUICfgJSON: `{
|
|
"ACLsEnabled": false,
|
|
"HCPEnabled": false,
|
|
"LocalDatacenter": "dc1",
|
|
"PrimaryDatacenter": "dc1",
|
|
"ContentPath": "/ui/",
|
|
"PeeringEnabled": true,
|
|
"UIConfig": {
|
|
"hcp_enabled": false,
|
|
"metrics_provider": "",
|
|
"metrics_proxy_enabled": false,
|
|
"dashboard_url_templates": null
|
|
},
|
|
"V2CatalogEnabled": false
|
|
}`,
|
|
},
|
|
{
|
|
// We do this redirect just for UI dir since the app is a single page app
|
|
// and any URL under the path should just load the index and let Ember do
|
|
// it's thing unless it's a specific asset URL in the filesystem.
|
|
name: "unknown paths to serve index",
|
|
cfg: basicUIEnabledConfig(),
|
|
path: "/foo-bar-bazz-qux",
|
|
wantStatus: http.StatusOK,
|
|
wantContains: []string{"<!-- CONSUL_VERSION:"},
|
|
},
|
|
{
|
|
name: "injecting metrics vars",
|
|
cfg: basicUIEnabledConfig(
|
|
withMetricsProvider("foo"),
|
|
withMetricsProviderOptions(`{"a-very-unlikely-string":1}`),
|
|
),
|
|
path: "/",
|
|
wantStatus: http.StatusOK,
|
|
wantContains: []string{
|
|
"<!-- CONSUL_VERSION:",
|
|
},
|
|
wantUICfgJSON: `{
|
|
"ACLsEnabled": false,
|
|
"HCPEnabled": false,
|
|
"LocalDatacenter": "dc1",
|
|
"PrimaryDatacenter": "dc1",
|
|
"ContentPath": "/ui/",
|
|
"PeeringEnabled": true,
|
|
"UIConfig": {
|
|
"hcp_enabled": false,
|
|
"metrics_provider": "foo",
|
|
"metrics_provider_options": {
|
|
"a-very-unlikely-string":1
|
|
},
|
|
"metrics_proxy_enabled": false,
|
|
"dashboard_url_templates": null
|
|
},
|
|
"V2CatalogEnabled": false
|
|
}`,
|
|
},
|
|
{
|
|
name: "acls enabled",
|
|
cfg: basicUIEnabledConfig(withACLs()),
|
|
path: "/",
|
|
wantStatus: http.StatusOK,
|
|
wantContains: []string{"<!-- CONSUL_VERSION:"},
|
|
wantUICfgJSON: `{
|
|
"ACLsEnabled": true,
|
|
"HCPEnabled": false,
|
|
"LocalDatacenter": "dc1",
|
|
"PrimaryDatacenter": "dc1",
|
|
"ContentPath": "/ui/",
|
|
"PeeringEnabled": true,
|
|
"UIConfig": {
|
|
"hcp_enabled": false,
|
|
"metrics_provider": "",
|
|
"metrics_proxy_enabled": false,
|
|
"dashboard_url_templates": null
|
|
},
|
|
"V2CatalogEnabled": false
|
|
}`,
|
|
},
|
|
{
|
|
name: "hcp enabled",
|
|
cfg: basicUIEnabledConfig(withHCPEnabled()),
|
|
path: "/",
|
|
wantStatus: http.StatusOK,
|
|
wantContains: []string{"<!-- CONSUL_VERSION:"},
|
|
wantUICfgJSON: `{
|
|
"ACLsEnabled": false,
|
|
"HCPEnabled": true,
|
|
"LocalDatacenter": "dc1",
|
|
"PrimaryDatacenter": "dc1",
|
|
"ContentPath": "/ui/",
|
|
"PeeringEnabled": true,
|
|
"UIConfig": {
|
|
"hcp_enabled": true,
|
|
"metrics_provider": "",
|
|
"metrics_proxy_enabled": false,
|
|
"dashboard_url_templates": null
|
|
},
|
|
"V2CatalogEnabled": false
|
|
}`,
|
|
},
|
|
{
|
|
name: "v2 catalog enabled",
|
|
cfg: basicUIEnabledConfig(withV2CatalogEnabled()),
|
|
path: "/",
|
|
wantStatus: http.StatusOK,
|
|
wantContains: []string{"<!-- CONSUL_VERSION:"},
|
|
wantUICfgJSON: `{
|
|
"ACLsEnabled": false,
|
|
"HCPEnabled": false,
|
|
"LocalDatacenter": "dc1",
|
|
"PrimaryDatacenter": "dc1",
|
|
"ContentPath": "/ui/",
|
|
"PeeringEnabled": true,
|
|
"UIConfig": {
|
|
"hcp_enabled": false,
|
|
"metrics_provider": "",
|
|
"metrics_proxy_enabled": false,
|
|
"dashboard_url_templates": null
|
|
},
|
|
"V2CatalogEnabled": true
|
|
}`,
|
|
},
|
|
{
|
|
name: "peering disabled",
|
|
cfg: basicUIEnabledConfig(
|
|
withPeeringDisabled(),
|
|
),
|
|
path: "/",
|
|
wantStatus: http.StatusOK,
|
|
wantContains: []string{"<!-- CONSUL_VERSION:"},
|
|
wantUICfgJSON: `{
|
|
"ACLsEnabled": false,
|
|
"HCPEnabled": false,
|
|
"LocalDatacenter": "dc1",
|
|
"PrimaryDatacenter": "dc1",
|
|
"ContentPath": "/ui/",
|
|
"PeeringEnabled": false,
|
|
"UIConfig": {
|
|
"hcp_enabled": false,
|
|
"metrics_provider": "",
|
|
"metrics_proxy_enabled": false,
|
|
"dashboard_url_templates": null
|
|
},
|
|
"V2CatalogEnabled": false
|
|
}`,
|
|
},
|
|
{
|
|
name: "external transformation",
|
|
cfg: basicUIEnabledConfig(
|
|
withMetricsProvider("foo"),
|
|
),
|
|
path: "/",
|
|
tx: func(data map[string]interface{}) error {
|
|
data["SSOEnabled"] = true
|
|
o := data["UIConfig"].(map[string]interface{})
|
|
o["metrics_provider"] = "bar"
|
|
return nil
|
|
},
|
|
wantStatus: http.StatusOK,
|
|
wantContains: []string{
|
|
"<!-- CONSUL_VERSION:",
|
|
},
|
|
wantUICfgJSON: `{
|
|
"ACLsEnabled": false,
|
|
"HCPEnabled": false,
|
|
"SSOEnabled": true,
|
|
"LocalDatacenter": "dc1",
|
|
"PrimaryDatacenter": "dc1",
|
|
"ContentPath": "/ui/",
|
|
"PeeringEnabled": true,
|
|
"UIConfig": {
|
|
"hcp_enabled": false,
|
|
"metrics_provider": "bar",
|
|
"metrics_proxy_enabled": false,
|
|
"dashboard_url_templates": null
|
|
},
|
|
"V2CatalogEnabled": false
|
|
}`,
|
|
},
|
|
{
|
|
name: "serving metrics provider js",
|
|
cfg: basicUIEnabledConfig(
|
|
withMetricsProvider("foo"),
|
|
withMetricsProviderFiles("testdata/foo.js", "testdata/bar.js"),
|
|
),
|
|
path: "/",
|
|
wantStatus: http.StatusOK,
|
|
wantContains: []string{
|
|
"<!-- CONSUL_VERSION:",
|
|
`<script src="/ui/assets/compiled-metrics-providers.js">`,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := NewHandler(tc.cfg, testutil.Logger(t), tc.tx)
|
|
|
|
req := httptest.NewRequest("GET", tc.path, nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, tc.wantStatus, rec.Code)
|
|
for _, want := range tc.wantContains {
|
|
require.Contains(t, rec.Body.String(), want)
|
|
}
|
|
if tc.wantUICfgJSON != "" {
|
|
require.JSONEq(t, tc.wantUICfgJSON, extractUIConfig(t, rec.Body.String()))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func extractApplicationJSON(t *testing.T, attrName, content string) string {
|
|
t.Helper()
|
|
|
|
var scriptContent *html.Node
|
|
var find func(node *html.Node)
|
|
|
|
// Recurse down the tree and pick out <script attrName=ourAttrName>
|
|
find = func(node *html.Node) {
|
|
if node.Type == html.ElementNode && node.Data == "script" {
|
|
for i := 0; i < len(node.Attr); i++ {
|
|
attr := node.Attr[i]
|
|
if attr.Key == attrName {
|
|
// find the script and save off the content, which in this case is
|
|
// the JSON we are looking for, once we have it finish up
|
|
scriptContent = node.FirstChild
|
|
return
|
|
}
|
|
}
|
|
}
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
find(child)
|
|
}
|
|
}
|
|
|
|
doc, err := html.Parse(strings.NewReader(content))
|
|
require.NoError(t, err)
|
|
|
|
find(doc)
|
|
|
|
var buf bytes.Buffer
|
|
w := io.Writer(&buf)
|
|
|
|
renderErr := html.Render(w, scriptContent)
|
|
require.NoError(t, renderErr)
|
|
|
|
jsonStr := html.UnescapeString(buf.String())
|
|
return jsonStr
|
|
}
|
|
|
|
func extractUIConfig(t *testing.T, content string) string {
|
|
t.Helper()
|
|
return extractApplicationJSON(t, "data-consul-ui-config", content)
|
|
}
|
|
|
|
type cfgFunc func(cfg *config.RuntimeConfig)
|
|
|
|
func basicUIEnabledConfig(opts ...cfgFunc) *config.RuntimeConfig {
|
|
cfg := &config.RuntimeConfig{
|
|
UIConfig: config.UIConfig{
|
|
Enabled: true,
|
|
ContentPath: "/ui/",
|
|
},
|
|
Datacenter: "dc1",
|
|
PrimaryDatacenter: "dc1",
|
|
PeeringEnabled: true,
|
|
}
|
|
for _, f := range opts {
|
|
f(cfg)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func withACLs() cfgFunc {
|
|
return func(cfg *config.RuntimeConfig) {
|
|
cfg.PrimaryDatacenter = "dc1"
|
|
cfg.ACLResolverSettings.ACLDefaultPolicy = "deny"
|
|
cfg.ACLsEnabled = true
|
|
}
|
|
}
|
|
|
|
func withMetricsProvider(name string) cfgFunc {
|
|
return func(cfg *config.RuntimeConfig) {
|
|
cfg.UIConfig.MetricsProvider = name
|
|
}
|
|
}
|
|
|
|
func withMetricsProviderFiles(names ...string) cfgFunc {
|
|
return func(cfg *config.RuntimeConfig) {
|
|
cfg.UIConfig.MetricsProviderFiles = names
|
|
}
|
|
}
|
|
|
|
func withMetricsProviderOptions(jsonStr string) cfgFunc {
|
|
return func(cfg *config.RuntimeConfig) {
|
|
cfg.UIConfig.MetricsProviderOptionsJSON = jsonStr
|
|
}
|
|
}
|
|
|
|
func withHCPEnabled() cfgFunc {
|
|
return func(cfg *config.RuntimeConfig) {
|
|
cfg.UIConfig.HCPEnabled = true
|
|
}
|
|
}
|
|
|
|
func withV2CatalogEnabled() cfgFunc {
|
|
return func(cfg *config.RuntimeConfig) {
|
|
cfg.Experiments = append(cfg.Experiments, "resource-apis")
|
|
}
|
|
}
|
|
|
|
func withPeeringDisabled() cfgFunc {
|
|
return func(cfg *config.RuntimeConfig) {
|
|
cfg.PeeringEnabled = false
|
|
}
|
|
}
|
|
|
|
// TestMultipleIndexRequests validates that the buffered file mechanism works
|
|
// beyond the first request. The initial implementation did not as it shared an
|
|
// bytes.Reader between callers.
|
|
func TestMultipleIndexRequests(t *testing.T) {
|
|
h := NewHandler(basicUIEnabledConfig(), testutil.Logger(t), nil)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "<!-- CONSUL_VERSION:",
|
|
"request %d didn't return expected content", i+1)
|
|
}
|
|
}
|
|
|
|
func TestReload(t *testing.T) {
|
|
h := NewHandler(basicUIEnabledConfig(), testutil.Logger(t), nil)
|
|
|
|
{
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "<!-- CONSUL_VERSION:")
|
|
require.NotContains(t, rec.Body.String(), "exotic-metrics-provider-name")
|
|
}
|
|
|
|
// Reload the config with the changed metrics provider name
|
|
newCfg := basicUIEnabledConfig(
|
|
withMetricsProvider("exotic-metrics-provider-name"),
|
|
)
|
|
h.ReloadConfig(newCfg)
|
|
|
|
// Now we should see the new provider name in the output of index
|
|
{
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "<!-- CONSUL_VERSION:")
|
|
require.Contains(t, rec.Body.String(), "exotic-metrics-provider-name")
|
|
}
|
|
}
|
|
|
|
func TestCustomDir(t *testing.T) {
|
|
uiDir := testutil.TempDir(t, "consul-uiserver")
|
|
defer os.RemoveAll(uiDir)
|
|
|
|
path := filepath.Join(uiDir, "test-file")
|
|
require.NoError(t, os.WriteFile(path, []byte("test"), 0644))
|
|
|
|
cfg := basicUIEnabledConfig()
|
|
cfg.UIConfig.Dir = uiDir
|
|
h := NewHandler(cfg, testutil.Logger(t), nil)
|
|
|
|
req := httptest.NewRequest("GET", "/test-file", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "test")
|
|
}
|
|
|
|
func TestCompiledJS(t *testing.T) {
|
|
cfg := basicUIEnabledConfig(
|
|
withMetricsProvider("foo"),
|
|
withMetricsProviderFiles("testdata/foo.js", "testdata/bar.js"),
|
|
)
|
|
h := NewHandler(cfg, testutil.Logger(t), nil)
|
|
|
|
paths := []string{
|
|
"/" + compiledProviderJSPath,
|
|
// We need to work even without the initial slash because the agent uses
|
|
// http.StripPrefix with the entire ContentPath which includes a trailing
|
|
// slash. This apparently works fine for the assetFS etc. so we need to
|
|
// also tolerate it when the URL doesn't have a slash at the start of the
|
|
// path.
|
|
compiledProviderJSPath,
|
|
}
|
|
|
|
for _, path := range paths {
|
|
t.Run(path, func(t *testing.T) {
|
|
// NewRequest doesn't like paths with no leading slash but we need to test
|
|
// a request with a URL that has that so just create with root path and
|
|
// then manually modify the URL path so it emulates one that has been
|
|
// doctored by http.StripPath.
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.URL.Path = path
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, rec.Result().Header["Content-Type"][0], "application/javascript")
|
|
wantCompiled, err := os.ReadFile("testdata/compiled-metrics-providers-golden.js")
|
|
require.NoError(t, err)
|
|
require.Equal(t, rec.Body.String(), string(wantCompiled))
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
func TestHandler_ServeHTTP_TransformIsEvaluatedOnEachRequest(t *testing.T) {
|
|
cfg := basicUIEnabledConfig()
|
|
|
|
value := "seeds"
|
|
transform := func(data map[string]interface{}) error {
|
|
data["apple"] = value
|
|
return nil
|
|
}
|
|
h := NewHandler(cfg, hclog.New(nil), transform)
|
|
|
|
t.Run("initial request", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
expected := `{
|
|
"ACLsEnabled": false,
|
|
"HCPEnabled": false,
|
|
"LocalDatacenter": "dc1",
|
|
"PrimaryDatacenter": "dc1",
|
|
"ContentPath": "/ui/",
|
|
"PeeringEnabled": true,
|
|
"UIConfig": {
|
|
"hcp_enabled": false,
|
|
"metrics_provider": "",
|
|
"metrics_proxy_enabled": false,
|
|
"dashboard_url_templates": null
|
|
},
|
|
"V2CatalogEnabled": false,
|
|
"apple": "seeds"
|
|
}`
|
|
require.JSONEq(t, expected, extractUIConfig(t, rec.Body.String()))
|
|
})
|
|
|
|
t.Run("transform value has changed", func(t *testing.T) {
|
|
|
|
value = "plant"
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
expected := `{
|
|
"ACLsEnabled": false,
|
|
"HCPEnabled": false,
|
|
"LocalDatacenter": "dc1",
|
|
"PrimaryDatacenter": "dc1",
|
|
"ContentPath": "/ui/",
|
|
"PeeringEnabled": true,
|
|
"UIConfig": {
|
|
"hcp_enabled": false,
|
|
"metrics_provider": "",
|
|
"metrics_proxy_enabled": false,
|
|
"dashboard_url_templates": null
|
|
},
|
|
"V2CatalogEnabled": false,
|
|
"apple": "plant"
|
|
}`
|
|
require.JSONEq(t, expected, extractUIConfig(t, rec.Body.String()))
|
|
})
|
|
}
|