Ctrl K
ComunidadGuías…

cli/config_defaults_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package cli

import (
	"bytes"
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/spf13/cobra"
	"github.com/spf13/viper"
	"go.keploy.io/server/v3/config"
	"go.uber.org/zap"
	yamlLib "gopkg.in/yaml.v3"
)

// `keploy config defaults` is where the settings a generated config leaves
// out have to remain readable. If it stops printing them, the short file just
// moves the question somewhere nobody can answer it.
func TestConfigDefaults_PrintsEverySetting(t *testing.T) {
	cmd := configDefaults(zap.NewNop())
	var out bytes.Buffer
	cmd.SetOut(&out)
	cmd.SetArgs(nil)
	if err := cmd.Execute(); err != nil {
		t.Fatal(err)
	}
	got := out.String()
	// EVERY top-level block, derived from the defaults themselves rather
	// than from a list six keys long: dropping the whole `mock:`, `contract:`
	// or `async:` block passed that list without a murmur.
	defaults, err := config.DefaultsYAML()
	if err != nil {
		t.Fatal(err)
	}
	var all map[string]interface{}
	if err := yamlLib.Unmarshal([]byte(defaults), &all); err != nil {
		t.Fatal(err)
	}
	if len(all) < 20 {
		t.Fatalf("only %d top-level settings in the defaults; this test is no longer checking anything", len(all))
	}
	for key := range all {
		if !strings.Contains(got, "\n"+key+":") && !strings.HasPrefix(got, key+":") {
			t.Errorf("the defaults listing does not mention %q", key)
		}
	}
	// ...and the documentation for the ones that need it.
	for _, explained := range []string{"strictMockWindow", "upstreamTls", "mysqlPorts"} {
		if !strings.Contains(got, explained) {
			t.Errorf("the listing omits %q", explained)
		}
	}
	if n := strings.Count(got, "#"); n < 30 {
		t.Errorf("the listing carries only %d comment lines; the settings it lists are unexplained", n)
	}
	if !strings.HasPrefix(got, "# Generated by Keploy") {
		t.Errorf("no version comment on the listing: %.40q", got)
	}
	// The header that tops a GENERATED file would be a lie here: this is the
	// full document, not the part that differs from it.
	if strings.Contains(got, "differ from Keploy's defaults") {
		t.Error("the full listing claims to hold only what differs")
	}
	// `agent` is Keploy's to manage; printing it invites editing a block the
	// generated file will never contain.
	if strings.Contains(got, "\nagent:") {
		t.Error("the listing offers the internally-managed agent block")
	}
}

// It is a document a developer can start from: printing it and writing it
// must produce the same bytes, and what it produces must be what Keploy
// applies -- i.e. stripping it against the defaults leaves nothing.
func TestConfigDefaults_OutputFileMatchesStdout(t *testing.T) {
	var out bytes.Buffer
	printed := configDefaults(zap.NewNop())
	printed.SetOut(&out)
	if err := printed.Execute(); err != nil {
		t.Fatal(err)
	}

	path := filepath.Join(t.TempDir(), "keploy.defaults.yml")
	saved := configDefaults(zap.NewNop())
	saved.SetOut(&bytes.Buffer{})
	saved.SetArgs([]string{"--output", path})
	if err := saved.Execute(); err != nil {
		t.Fatal(err)
	}
	body, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	// The SETTINGS are the same; the saved copy carries one thing more. A
	// document a repository may keep has to say what keeping it costs --
	// freezing today's defaults there, which is the anti-pattern the whole
	// minimal config exists to end, and the command's own help recommends
	// `-o keploy.yml` as a starting point.
	if !strings.Contains(string(body), "FREEZES") {
		t.Errorf("the saved defaults do not say what keeping them costs:\n%s", body)
	}
	settings := func(doc string) string {
		var kept []string
		for _, line := range strings.Split(doc, "\n") {
			if t := strings.TrimSpace(line); t != "" && !strings.HasPrefix(t, "#") {
				kept = append(kept, line)
			}
		}
		return strings.Join(kept, "\n")
	}
	if settings(string(body)) != settings(out.String()) {
		t.Fatalf("-o wrote different SETTINGS from what it prints:\n--- file\n%s\n--- stdout\n%s", body, out.String())
	}

	// The real property, and the reason to print them at all: saving this
	// document as keploy.yml must leave the effective config exactly as it
	// would have been with no file. (Comparing StripDefaults(defaults,
	// defaults) instead was a tautology -- the same function on both sides.)
	dir := t.TempDir()
	cfgPath := filepath.Join(dir, "keploy.yml")
	if err := os.WriteFile(cfgPath, []byte(out.String()), 0644); err != nil {
		t.Fatal(err)
	}
	v := viper.New()
	v.SetConfigFile(cfgPath)
	if err := v.ReadInConfig(); err != nil {
		t.Fatalf("what this prints is not a config Keploy can read: %v", err)
	}
	fromFile := config.New()
	if err := v.Unmarshal(fromFile); err != nil {
		t.Fatal(err)
	}
	withFile, err := yamlLib.Marshal(fromFile)
	if err != nil {
		t.Fatal(err)
	}
	withoutFile, err := yamlLib.Marshal(config.New())
	if err != nil {
		t.Fatal(err)
	}
	if string(withFile) != string(withoutFile) {
		t.Fatalf("saving the printed defaults changed the effective config:\n--- with the file\n%s\n--- with none\n%s", withFile, withoutFile)
	}
}

// The obvious target is keploy.yml, and 134 lines of defaults written over a
// developer's own config would take their command with it -- silently, exit
// 0. `keploy config --generate` asks before it overwrites; this is pipeable,
// so it refuses and names the way past.
func TestConfigDefaults_RefusesToOverwriteWithoutForce(t *testing.T) {
	path := filepath.Join(t.TempDir(), "keploy.yml")
	mine := "command: npm test\ntest:\n    delay: 42\n"
	if err := os.WriteFile(path, []byte(mine), 0644); err != nil {
		t.Fatal(err)
	}

	cmd := configDefaults(zap.NewNop())
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	cmd.SetArgs([]string{"-o", path})
	err := cmd.Execute()
	if err == nil {
		t.Fatal("overwrote an existing config and reported success")
	}
	if !strings.Contains(err.Error(), "--force") {
		t.Errorf("the refusal does not name the way past: %v", err)
	}
	body, readErr := os.ReadFile(path)
	if readErr != nil {
		t.Fatal(readErr)
	}
	if string(body) != mine {
		t.Fatalf("the developer's config was modified:\n%s", body)
	}

	// ...and --force is the way past.
	forced := configDefaults(zap.NewNop())
	forced.SetOut(&bytes.Buffer{})
	forced.SetArgs([]string{"-o", path, "--force"})
	if err := forced.Execute(); err != nil {
		t.Fatal(err)
	}
	body, readErr = os.ReadFile(path)
	if readErr != nil {
		t.Fatal(readErr)
	}
	if !strings.Contains(string(body), "proxyPort:") {
		t.Fatalf("--force did not write the defaults:\n%s", body)
	}
}

// A stray argument is a typo, not a request to print everything.
func TestConfigDefaults_RejectsStrayArguments(t *testing.T) {
	cmd := configDefaults(zap.NewNop())
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	cmd.SetArgs([]string{"keploy.yml"})
	if err := cmd.Execute(); err == nil {
		t.Fatal("a stray argument was accepted")
	}
}

// A failure has to be reported, not logged past: `keploy config defaults -o
// /nope/keploy.yml` that prints a success line and writes nothing is how a
// script silently ships no config at all.
func TestConfigDefaults_UnwritableOutputIsAnError(t *testing.T) {
	cmd := configDefaults(zap.NewNop())
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	cmd.SetArgs([]string{"-o", filepath.Join(t.TempDir(), "no-such-dir", "keploy.yml")})
	if err := cmd.Execute(); err == nil {
		t.Fatal("writing into a directory that does not exist reported success")
	}
}

// --- `keploy config --generate` -------------------------------------------
//
// Nothing constructed the `config` command itself, so its whole RunE -- the
// path the flag names, the refusal to clobber, the telemetry lookup -- had no
// coverage at all. Reverting either fix left the suite green.

type stubConfigurator struct{ path string }

func (s *stubConfigurator) AddFlags(cmd *cobra.Command) error {
	// The same flags cli/provider registers for `config`. A stub that is
	// missing one does not simplify the test, it tests a command that does
	// not exist -- and the RunE now refuses outright when a flag it needs is
	// not registered, rather than quietly reading it as false.
	cmd.Flags().Bool("generate", false, "")
	cmd.Flags().Bool("force", false, "")
	cmd.Flags().StringP("path", "p", ".", "")
	if s.path != "" {
		return cmd.Flags().Set("path", s.path)
	}
	return nil
}
func (s *stubConfigurator) ValidateFlags(_ context.Context, _ *cobra.Command) error { return nil }
func (s *stubConfigurator) Validate(_ context.Context, _ *cobra.Command) error      { return nil }

type stubFactory struct{ calls int }

func (f *stubFactory) GetService(_ context.Context, _ string) (interface{}, error) {
	f.calls++
	return nil, nil
}

func configCmd(t *testing.T, cfg *config.Config, path string) (*cobra.Command, *stubFactory) {
	t.Helper()
	f := &stubFactory{}
	cmd := Config(context.Background(), zap.NewNop(), cfg, f, &stubConfigurator{path: path})
	if cmd == nil {
		t.Fatal("the config command could not be built")
	}
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	return cmd, f
}

func TestConfigGenerate_WritesWhereThePathFlagSays(t *testing.T) {
	// cfg.Path is permanently empty here: root.go allocates a second
	// config.Config and hands THAT to every command constructor, while
	// ValidateFlags writes into the one main.go made. Reading cfg.Path meant
	// `--path ./svc` wrote keploy.yml into the current directory instead --
	// a silent overwrite of a file the user did not name.
	dir := t.TempDir()
	svc := filepath.Join(dir, "svc")
	if err := os.MkdirAll(svc, 0o755); err != nil {
		t.Fatal(err)
	}
	cwd, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}
	if err := os.Chdir(dir); err != nil {
		t.Fatal(err)
	}
	defer func() { _ = os.Chdir(cwd) }()

	cmd, factory := configCmd(t, config.New(), svc)
	cmd.SetArgs([]string{"--generate", "--path", svc})
	if err := cmd.Execute(); err != nil {
		t.Fatal(err)
	}
	if _, err := os.Stat(filepath.Join(svc, "keploy.yml")); err != nil {
		t.Fatalf("--path was ignored; nothing at %s: %v", svc, err)
	}
	if _, err := os.Stat(filepath.Join(dir, "keploy.yml")); err == nil {
		t.Fatal("--path was ignored: the config landed in the current directory")
	}
	// Every other command pings telemetry through the service lookup, and
	// `config --generate` is an onboarding step.
	if factory.calls == 0 {
		t.Error("config --generate reported nothing; it is the one command in the CLI that goes unseen")
	}
}

func TestConfigGenerate_DoesNotWriteThroughADanglingSymlink(t *testing.T) {
	// Stat follows the link, so a dangling keploy.yml read as "no file here":
	// the command never asked, and wrote through the link to a path the user
	// never named. The -o flag of `config defaults` was fixed for exactly
	// this; the more dangerous caller was left alone.
	//
	// OUTSIDE the directory, which is what makes it dangerous. A dangling
	// link pointing INSIDE is the committed-monorepo layout and is written
	// through on purpose -- see the case below, and
	// TestWriteMinimalConfig_DanglingLinkInsideIsWrittenThrough.
	outer := t.TempDir()
	dir := filepath.Join(outer, "project")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	target := filepath.Join(outer, "somewhere-else.yml")
	if err := os.Symlink(target, filepath.Join(dir, "keploy.yml")); err != nil {
		t.Skipf("symlinks unavailable: %v", err)
	}
	// No --force and no terminal, deliberately: a symlink is refused for
	// being a symlink, and must not be reported as "there is nobody here to
	// confirm overwriting it" -- an answer that sends the user to a flag
	// which would refuse for the same real reason anyway.
	cmd, _ := configCmd(t, config.New(), dir)
	cmd.SetArgs([]string{"--generate", "--path", dir})
	// The ERROR as well as the absence: swallowing it would print "Config
	// file generated successfully" over a file that was never written, and
	// asserting only the absence cannot tell the two apart.
	if err := cmd.Execute(); err == nil {
		t.Fatal("config --generate reported success for a config it refused to write")
	} else if !strings.Contains(err.Error(), "symbolic link") {
		t.Errorf("the refusal does not say why: %v", err)
	}
	if _, err := os.Stat(target); err == nil {
		t.Fatal("config --generate wrote through a dangling symlink to a file the user never named")
	}
}

// ...and the layout that link style exists for: a keploy.yml committed as a
// link to the file the generator has not written yet. Refusing it took the
// generator away from those repositories, and the refusal said the link
// pointed outside the directory when it pointed squarely inside it.
func TestConfigGenerate_WritesThroughADanglingLinkInsideTheDirectory(t *testing.T) {
	dir := t.TempDir()
	if err := os.MkdirAll(filepath.Join(dir, "config"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink(filepath.Join("config", "keploy.yml"), filepath.Join(dir, "keploy.yml")); err != nil {
		t.Skipf("symlinks unavailable: %v", err)
	}
	cmd, _ := configCmd(t, config.New(), dir)
	cmd.SetArgs([]string{"--generate", "--path", dir})
	if err := cmd.Execute(); err != nil {
		t.Fatalf("a committed link to a not-yet-generated config was refused: %v", err)
	}
	if _, err := os.Stat(filepath.Join(dir, "config", "keploy.yml")); err != nil {
		t.Fatalf("nothing was written through the link: %v", err)
	}
}

// --force means "overwrite the file I named", not "write through a link to
// one I did not". The non-force branch refused a dangling link; --force wrote
// straight through it, outside the project, and reported success.
func TestConfigDefaults_ForceDoesNotWriteThroughASymlink(t *testing.T) {
	dir := t.TempDir()
	outside := filepath.Join(dir, "outside.yml")
	link := filepath.Join(dir, "link.yml")
	if err := os.Symlink(outside, link); err != nil {
		t.Skipf("symlinks unavailable: %v", err)
	}
	cmd := configDefaults(zap.NewNop())
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	cmd.SetArgs([]string{"-o", link, "--force"})
	if err := cmd.Execute(); err == nil {
		t.Fatal("--force wrote through a symbolic link and reported success")
	}
	if _, err := os.Stat(outside); err == nil {
		t.Fatal("--force created a file outside the path the user named")
	}
}

// ...and it does not leave a file the way it found it, if that way was
// world-writable. os.WriteFile does not change an existing file's mode.
func TestConfigDefaults_ForceLeavesTheFileReadableOnly(t *testing.T) {
	path := filepath.Join(t.TempDir(), "keploy.yml")
	if err := os.WriteFile(path, []byte("command: npm test\n"), 0o777); err != nil {
		t.Fatal(err)
	}
	// CHMOD, not the WriteFile mode: the umask strips exactly the bits this
	// test is about, so the fixture was never world-writable.
	if err := os.Chmod(path, 0o777); err != nil {
		t.Fatal(err)
	}
	cmd := configDefaults(zap.NewNop())
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	cmd.SetArgs([]string{"-o", path, "--force"})
	if err := cmd.Execute(); err != nil {
		t.Fatal(err)
	}
	info, err := os.Stat(path)
	if err != nil {
		t.Fatal(err)
	}
	if mode := info.Mode().Perm(); mode&0o022 != 0 {
		t.Errorf("keploy.yml is still group/world-writable (%04o) after --force", mode)
	}
}