Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions cmd/d8/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
Copyright 2026 Flant JSC

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

http://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 main

import (
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

dkplog "github.com/deckhouse/deckhouse/pkg/log"

"github.com/deckhouse/deckhouse-cli/internal/plugins/flags"
)

// TestNewRootCommand_WritesNothingToStdout is the regression test for the
// v0.22.9 fix (commit cf9b345, ticket 503982, 12 Nov 2025).
//
// A kubeconfig issued by user-authn authenticates through an `exec` block:
// client-go runs `d8` as a subprocess and decodes its *stdout* as an
// ExecCredential object (the payload comes from `d8 login get-token`,
// cmd/commands/login.go). In v0.22.8 startup printed
//
// {"level":"warn","logger":"d8","msg":"Failed to read plugins directory",
// "error":"open /opt/deckhouse/lib/deckhouse-cli/plugins: no such file or directory"}
//
// to that same stdout, so client-go saw two JSON documents and every `d8 k`
// died with `decoding stdout: couldn't get version/kind; json parse error:
// invalid character '{' after top-level value`.
//
// The invariant: building the command tree writes nothing to stdout, even
// when the plugins directory cannot be read. The plugins directory is read
// during construction (root.go:97 registerCommands -> installedPlugins ->
// layout.ResolveInstalled), and root.go:67 builds the logger that used to
// carry the message - so this test covers both halves of the failure.
func TestNewRootCommand_WritesNothingToStdout(t *testing.T) {
// NewRootCommand overwrites this package-level global from the
// environment (root.go:92-95).
restore := flags.DeckhousePluginsDir
t.Cleanup(func() { flags.DeckhousePluginsDir = restore })

t.Setenv(flags.EnvPluginsDir, unreadablePluginsPath(t))

// Pin the level this test is about. root.go:67 takes it from the
// environment, and a developer with LOG_LEVEL=debug exported would
// otherwise get a red test for a different, still-open defect: at debug
// level root.go:257 does print JSON to stdout, because dkplog defaults to
// os.Stdout (deckhouse/pkg/log logger.go:110) and nothing here passes
// WithOutput. That one is fixed by dkplog.WithOutput(os.Stderr), not by
// this test.
t.Setenv("LOG_LEVEL", "")

stdout := captureStdout(t, func() {
NewRootCommand()
})

assert.Empty(t, stdout,
"CLI startup must leave stdout untouched: anything here is prepended to "+
"the ExecCredential JSON and breaks kubeconfig exec authorization")
}

// TestCaptureStdout_SeesLoggerOutput is the sensitivity control. dkplog
// defaults to os.Stdout (deckhouse/pkg/log logger.go:110) and nothing in this
// repository passes WithOutput, so a logger built the way root.go:67 builds
// one lands in the capture. Without this check the test above could pass
// because the capture is broken rather than because stdout is clean.
func TestCaptureStdout_SeesLoggerOutput(t *testing.T) {
stdout := captureStdout(t, func() {
// Same construction as root.go:67, then the call that broke v0.22.8.
dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)).
Named("d8").
Warn("Failed to read plugins directory")
})

assert.Contains(t, stdout, `"msg":"Failed to read plugins directory"`,
"the capture must see a log line written the way v0.22.8 wrote it")
}

// captureStdout swaps os.Stdout for a pipe, runs fn, and returns everything
// fn wrote there. The swap has to happen before fn builds any logger: dkplog
// resolves os.Stdout once, at logger construction.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()

reader, writer, err := os.Pipe()
require.NoError(t, err)

saved := os.Stdout
os.Stdout = writer

// Drain concurrently so fn cannot block on a full pipe buffer.
captured := make(chan string, 1)
go func() {
var sb strings.Builder
_, _ = io.Copy(&sb, reader)
captured <- sb.String()
}()

defer func() {
os.Stdout = saved
_ = reader.Close()
}()

fn()

require.NoError(t, writer.Close())

return <-captured
}

// unreadablePluginsPath returns a path that neither os.MkdirAll nor
// os.ReadDir can succeed on, because its parent is a regular file (ENOTDIR).
// That reproduces the field's absent /opt/deckhouse/lib/deckhouse-cli for any
// user, including root - where a merely missing path would just be created.
func unreadablePluginsPath(t *testing.T) string {
t.Helper()

blocker := filepath.Join(t.TempDir(), "blocker")
require.NoError(t, os.WriteFile(blocker, nil, 0o600))

return filepath.Join(blocker, "deckhouse-cli")
}
139 changes: 139 additions & 0 deletions testing/e2e/auth/exec_credential_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
Copyright 2026 Flant JSC

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

http://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.
*/

// End-to-end counterpart of TestNewRootCommand_WritesNothingToStdout
// (cmd/d8/root_test.go): the same invariant, checked on the built binary
// instead of on NewRootCommand in-process.
//
// Both are needed. The unit test guards every CI run and fails fast on a
// regression in the command tree. This one is the only check that covers the
// artifact users actually get: build tags, linked-in libraries and anything
// that writes to file descriptor 1 without going through this package's
// logger.
//
// The regression (v0.22.9, commit cf9b345, ticket 503982, 12 Nov 2025): a
// kubeconfig issued by user-authn authenticates through an `exec` block, so
// client-go runs `d8` as a subprocess and decodes its *stdout* as an
// ExecCredential object (the payload comes from `d8 login get-token`,
// cmd/commands/login.go). In v0.22.8 startup printed
//
// {"level":"warn","logger":"d8","msg":"Failed to read plugins directory",
// "error":"open /opt/deckhouse/lib/deckhouse-cli/plugins: no such file or directory"}
//
// to that same stdout, so client-go saw two JSON documents and every `d8 k`
// died with `decoding stdout: couldn't get version/kind; json parse error:
// invalid character '{' after top-level value`. The customer first noticed
// the stray line in the output of `d8 --version`, which is why that command
// is the probe here: its stdout is exactly one known line.
package auth

import (
"bytes"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// binEnv points the test at a specific binary, overriding the lookup below.
// Useful for checking a release artifact: D8_BIN=/path/to/d8 go test ...
const binEnv = "D8_BIN"

// repoRoot is the repository root relative to this package: go test runs with
// the package directory as the working directory.
const repoRoot = "../../.."

// TestD8Stdout_CleanWhenPluginsDirUnreadable runs the built binary with the
// plugins directory pointed at a path that can be neither created nor read -
// the field condition - and requires stdout to hold nothing but the output of
// the command that was asked for.
func TestD8Stdout_CleanWhenPluginsDirUnreadable(t *testing.T) {
cmd := exec.Command(d8Binary(t), "--version")
cmd.Env = append(os.Environ(),
"DECKHOUSE_CLI_PATH="+unreadablePluginsPath(t),
// Pin the level this test is about. At debug level the CLI does still
// print JSON to stdout (cmd/d8/root.go: dkplog defaults to os.Stdout
// and no WithOutput is passed) - a separate, still-open defect that
// dkplog.WithOutput(os.Stderr) would close.
"LOG_LEVEL=",
)

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

runErr := cmd.Run()

// Asserted before the exit status: stdout cleanliness is the invariant,
// and a binary that also fails for another reason must not hide it.
assert.NotContains(t, stdout.String(), `{"level":`,
"a JSON log line on stdout breaks kubeconfig exec authorization: client-go "+
"decodes this stream as an ExecCredential; stdout:\n%s", stdout.String())

require.NoError(t, runErr, "d8 --version must succeed; stderr:\n%s", stderr.String())

lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n")
require.Len(t, lines, 1,
"stdout must carry exactly one line, the version output; got:\n%s", stdout.String())
assert.Regexp(t, `^d8 version \S+$`, lines[0],
"the only line on stdout must be the version output")
}

// d8Binary locates the binary under test: the D8_BIN override first, then the
// two places the Taskfile and testing/e2e/plugins/test.sh put builds. When
// none exists the test skips rather than fails - a plain `go test ./...` on a
// clean checkout has no binary to check, and the in-process unit test in
// cmd/d8 is what guards CI.
func d8Binary(t *testing.T) string {
t.Helper()

if fromEnv := os.Getenv(binEnv); fromEnv != "" {
return fromEnv
}

candidates := []string{
filepath.Join(repoRoot, "bin", "d8"),
filepath.Join(repoRoot, "build", runtime.GOOS+"-"+runtime.GOARCH, "bin", "d8"),
}

for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}

t.Skipf("no d8 binary in %v; build one (task build:dev) or set %s", candidates, binEnv)

return ""
}

// unreadablePluginsPath returns a path that neither os.MkdirAll nor
// os.ReadDir can succeed on, because its parent is a regular file (ENOTDIR).
// That reproduces the field's absent /opt/deckhouse/lib/deckhouse-cli for any
// user, including root - where a merely missing path would just be created.
func unreadablePluginsPath(t *testing.T) string {
t.Helper()

blocker := filepath.Join(t.TempDir(), "blocker")
require.NoError(t, os.WriteFile(blocker, nil, 0o600))

return filepath.Join(blocker, "deckhouse-cli")
}
Loading