mirror of
https://github.com/openziti/ziti.git
synced 2026-09-10 08:45:41 +00:00
334 lines
11 KiB
Go
334 lines
11 KiB
Go
//go:build cli_tests
|
|
|
|
/*
|
|
Copyright NetFoundry 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
|
|
|
|
https://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 cli_tests
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/openziti/ziti/v2/ziti/cmd"
|
|
"github.com/stretchr/testify/require"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// skipIfNoGitHub skips the calling test when the GitHub releases API is unreachable or
|
|
// rate-limiting, so an offline or rate-limited run does not fail for reasons unrelated to the
|
|
// code under test. Unauthenticated GitHub API calls are capped at 60/hr.
|
|
func skipIfNoGitHub(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodHead,
|
|
"https://api.github.com/repos/openziti/ziti-console/releases?per_page=1", nil)
|
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
|
if err != nil {
|
|
t.Skipf("skipping network test: GitHub unreachable: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
|
t.Skipf("skipping network test: GitHub rate-limited (status %d)", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// runZiti drives the ziti command tree in-process with an explicit arg slice (so paths with
|
|
// spaces are not mangled) and returns command output plus the command error. Output is
|
|
// captured through a buffer wired into the command rather than by swapping os.Stdout, which
|
|
// avoids the os.Pipe deadlock that occurs when output exceeds the pipe buffer.
|
|
func runZiti(args ...string) (string, error) {
|
|
var buf bytes.Buffer
|
|
root := cmd.NewRootCommand(os.Stdin, &buf, &buf)
|
|
root.SetArgs(args)
|
|
root.SetOut(&buf)
|
|
root.SetErr(&buf)
|
|
|
|
err := root.Execute()
|
|
return buf.String(), err
|
|
}
|
|
|
|
const sampleControllerConfig = `# top-level controller config
|
|
v: 3
|
|
|
|
# web listeners hosted by the controller
|
|
web:
|
|
- name: client-management
|
|
bindPoints:
|
|
- interface: 0.0.0.0:1280
|
|
address: localhost:1280
|
|
# APIs bound to this listener
|
|
apis:
|
|
- binding: edge-management
|
|
options: { }
|
|
- binding: edge-client
|
|
options: { }
|
|
- name: dark-apis
|
|
bindPoints:
|
|
- interface: 0.0.0.0:1281
|
|
address: localhost:1281
|
|
apis:
|
|
- binding: edge-client
|
|
options: { }
|
|
`
|
|
|
|
// parsedConfig mirrors the slice of web listeners and their api bindings for assertions.
|
|
type parsedConfig struct {
|
|
Web []struct {
|
|
Name string `yaml:"name"`
|
|
Apis []struct {
|
|
Binding string `yaml:"binding"`
|
|
Options struct {
|
|
Path string `yaml:"path"`
|
|
Location string `yaml:"location"`
|
|
IndexFile string `yaml:"indexFile"`
|
|
} `yaml:"options"`
|
|
} `yaml:"apis"`
|
|
} `yaml:"web"`
|
|
}
|
|
|
|
func parseConfig(t *testing.T, path string) parsedConfig {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
var cfg parsedConfig
|
|
require.NoError(t, yaml.Unmarshal(data, &cfg))
|
|
return cfg
|
|
}
|
|
|
|
// spaCount returns how many spa bindings the named listener has, and the first spa options.
|
|
func spaForListener(t *testing.T, cfg parsedConfig, name string) (count int, path, location, indexFile string) {
|
|
t.Helper()
|
|
for _, l := range cfg.Web {
|
|
if l.Name != name {
|
|
continue
|
|
}
|
|
for _, a := range l.Apis {
|
|
if a.Binding == "spa" {
|
|
if count == 0 {
|
|
path = a.Options.Path
|
|
location = a.Options.Location
|
|
indexFile = a.Options.IndexFile
|
|
}
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
return count, path, location, indexFile
|
|
}
|
|
|
|
func writeSampleConfig(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "controller.yaml")
|
|
require.NoError(t, os.WriteFile(cfgPath, []byte(sampleControllerConfig), 0o644))
|
|
return cfgPath
|
|
}
|
|
|
|
// fakeConsoleDir creates a directory that looks like an installed console (has index.html), so
|
|
// `ziti ops console configure` treats the assets as present and does not try to download.
|
|
func fakeConsoleDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir := filepath.Join(t.TempDir(), "console")
|
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644))
|
|
return dir
|
|
}
|
|
|
|
// Test_Console_Ops exercises `ziti ops console configure` and the no-network validation
|
|
// paths of `ziti ops console download`. It does not require a running controller, a built
|
|
// binary, or network access, so it stays a reliable guard that these commands keep working.
|
|
func Test_Console_Ops(t *testing.T) {
|
|
t.Run("commands are registered", func(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"ops", "console", "--help"},
|
|
{"ops", "console", "download", "--help"},
|
|
{"ops", "console", "configure", "--help"},
|
|
} {
|
|
_, err := runZiti(args...)
|
|
require.NoError(t, err, "help for %v", args)
|
|
}
|
|
})
|
|
|
|
t.Run("configure single listener by name", func(t *testing.T) {
|
|
cfgPath := writeSampleConfig(t)
|
|
location := fakeConsoleDir(t)
|
|
|
|
_, err := runZiti("ops", "console", "configure", cfgPath, "--name", "client-management", "--location", location)
|
|
require.NoError(t, err)
|
|
|
|
cfg := parseConfig(t, cfgPath)
|
|
|
|
count, path, loc, index := spaForListener(t, cfg, "client-management")
|
|
require.Equal(t, 1, count, "client-management should have exactly one spa binding")
|
|
require.Equal(t, "zac", path)
|
|
require.Equal(t, location, loc)
|
|
require.Equal(t, "index.html", index)
|
|
|
|
darkCount, _, _, _ := spaForListener(t, cfg, "dark-apis")
|
|
require.Equal(t, 0, darkCount, "dark-apis should be untouched")
|
|
})
|
|
|
|
t.Run("configure all is idempotent", func(t *testing.T) {
|
|
cfgPath := writeSampleConfig(t)
|
|
location := fakeConsoleDir(t)
|
|
|
|
_, err := runZiti("ops", "console", "configure", cfgPath, "--all", "--location", location)
|
|
require.NoError(t, err)
|
|
// second run must update in place, not append a duplicate
|
|
_, err = runZiti("ops", "console", "configure", cfgPath, "--all", "--location", location)
|
|
require.NoError(t, err)
|
|
|
|
cfg := parseConfig(t, cfgPath)
|
|
for _, name := range []string{"client-management", "dark-apis"} {
|
|
count, _, loc, _ := spaForListener(t, cfg, name)
|
|
require.Equal(t, 1, count, "%s should have exactly one spa binding after two --all runs", name)
|
|
require.Equal(t, location, loc)
|
|
}
|
|
})
|
|
|
|
t.Run("configure multiple names", func(t *testing.T) {
|
|
cfgPath := writeSampleConfig(t)
|
|
location := fakeConsoleDir(t)
|
|
|
|
_, err := runZiti("ops", "console", "configure", cfgPath,
|
|
"--name", "client-management", "--name", "dark-apis", "--location", location)
|
|
require.NoError(t, err)
|
|
|
|
cfg := parseConfig(t, cfgPath)
|
|
for _, name := range []string{"client-management", "dark-apis"} {
|
|
count, _, _, _ := spaForListener(t, cfg, name)
|
|
require.Equal(t, 1, count, "%s should have a spa binding", name)
|
|
}
|
|
})
|
|
|
|
t.Run("configure custom path and index file", func(t *testing.T) {
|
|
cfgPath := writeSampleConfig(t)
|
|
location := fakeConsoleDir(t)
|
|
|
|
_, err := runZiti("ops", "console", "configure", cfgPath, "--name", "client-management",
|
|
"--location", location, "--path", "admin", "--index-file", "main.html")
|
|
require.NoError(t, err)
|
|
|
|
cfg := parseConfig(t, cfgPath)
|
|
_, path, _, index := spaForListener(t, cfg, "client-management")
|
|
require.Equal(t, "admin", path)
|
|
require.Equal(t, "main.html", index)
|
|
})
|
|
|
|
t.Run("configure unknown name errors and leaves file unchanged", func(t *testing.T) {
|
|
cfgPath := writeSampleConfig(t)
|
|
before, err := os.ReadFile(cfgPath)
|
|
require.NoError(t, err)
|
|
|
|
_, err = runZiti("ops", "console", "configure", cfgPath, "--name", "nope", "--location", fakeConsoleDir(t))
|
|
require.Error(t, err)
|
|
|
|
after, readErr := os.ReadFile(cfgPath)
|
|
require.NoError(t, readErr)
|
|
require.Equal(t, string(before), string(after), "file must not change on error")
|
|
})
|
|
|
|
t.Run("configure rejects --all with --name", func(t *testing.T) {
|
|
cfgPath := writeSampleConfig(t)
|
|
_, err := runZiti("ops", "console", "configure", cfgPath, "--all", "--name", "client-management",
|
|
"--location", fakeConsoleDir(t))
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("configure with no selector applies to all", func(t *testing.T) {
|
|
cfgPath := writeSampleConfig(t)
|
|
// -y assumes all when neither --all nor --name is given
|
|
_, err := runZiti("ops", "console", "configure", cfgPath, "--location", fakeConsoleDir(t), "-y")
|
|
require.NoError(t, err)
|
|
|
|
cfg := parseConfig(t, cfgPath)
|
|
for _, name := range []string{"client-management", "dark-apis"} {
|
|
count, _, _, _ := spaForListener(t, cfg, name)
|
|
require.Equal(t, 1, count, "%s should have a spa binding", name)
|
|
}
|
|
})
|
|
|
|
t.Run("configure requires location with --yes", func(t *testing.T) {
|
|
cfgPath := writeSampleConfig(t)
|
|
// --yes cannot prompt for the location, so it must be supplied
|
|
_, err := runZiti("ops", "console", "configure", cfgPath, "--all", "-y")
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("download requires location", func(t *testing.T) {
|
|
_, err := runZiti("ops", "console", "download", "4.3.0")
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("download refuses a non-empty directory", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("keep"), 0o644))
|
|
|
|
_, err := runZiti("ops", "console", "download", "4.3.0", "--location", dir)
|
|
require.Error(t, err)
|
|
|
|
// the pre-existing file must be untouched
|
|
_, statErr := os.Stat(filepath.Join(dir, "keep.txt"))
|
|
require.NoError(t, statErr)
|
|
})
|
|
|
|
// These hit the openziti/ziti-console GitHub releases over the network on purpose: they
|
|
// are the guard that "latest" resolution and concrete-version downloads keep working.
|
|
t.Run("download latest installs a usable console", func(t *testing.T) {
|
|
skipIfNoGitHub(t)
|
|
dir := filepath.Join(t.TempDir(), "console")
|
|
|
|
_, err := runZiti("ops", "console", "download", "--location", dir)
|
|
require.NoError(t, err)
|
|
|
|
// a usable SPA must have an index.html
|
|
_, statErr := os.Stat(filepath.Join(dir, "index.html"))
|
|
require.NoError(t, statErr, "downloaded console must contain index.html")
|
|
|
|
// the install records the resolved version
|
|
version, vErr := os.ReadFile(filepath.Join(dir, ".version"))
|
|
require.NoError(t, vErr)
|
|
require.NotEmpty(t, string(version))
|
|
})
|
|
|
|
t.Run("download a specific version installs that version", func(t *testing.T) {
|
|
skipIfNoGitHub(t)
|
|
|
|
// Discover a currently-published version rather than pinning a literal that may be
|
|
// yanked or superseded, then download it explicitly and confirm it is what we asked for.
|
|
latestDir := filepath.Join(t.TempDir(), "latest")
|
|
_, err := runZiti("ops", "console", "download", "--location", latestDir)
|
|
require.NoError(t, err)
|
|
v, vErr := os.ReadFile(filepath.Join(latestDir, ".version"))
|
|
require.NoError(t, vErr)
|
|
wantVersion := strings.TrimSpace(string(v))
|
|
require.NotEmpty(t, wantVersion)
|
|
|
|
dir := filepath.Join(t.TempDir(), "console")
|
|
_, err = runZiti("ops", "console", "download", wantVersion, "--location", dir)
|
|
require.NoError(t, err)
|
|
|
|
_, statErr := os.Stat(filepath.Join(dir, "index.html"))
|
|
require.NoError(t, statErr, "downloaded console must contain index.html")
|
|
|
|
version, vErr := os.ReadFile(filepath.Join(dir, ".version"))
|
|
require.NoError(t, vErr)
|
|
require.Equal(t, wantVersion, strings.TrimSpace(string(version)))
|
|
})
|
|
}
|