check / check (push) Failing after 2s
Ported per the library's v2-to-v3 migration guide: the app is a root cli.Command, actions take a context and the command, and flag environment variables become value sources. -v, -q and the version flag are local so, as before, only the commands defining them accept them. Every command stops reading flags at its first argument, as v2 did. ErrWriter is stdout so usage errors print with their help. The action's context reaches the manifest download in check, export and list. testify rises to v1.12.1, which v3 requires; the urfave_cli_no_docs build tag, which v3 lacks, is dropped. v3 accepts a flag given under two names, so -v --verbose gives debug output, and the test pinning the refusal becomes a TestVerboseCount case. Model: opus-5-5
384 lines
11 KiB
Go
384 lines
11 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
urfcli "github.com/urfave/cli/v3"
|
|
"sneak.berlin/go/mfer/mfer"
|
|
)
|
|
|
|
// These tests pin the exact rendered text of the CLI's user-visible error
|
|
// messages. The messages are grepped for in CI pipelines and quoted in bug
|
|
// reports, so a reword is a deliberate change, never a refactoring side
|
|
// effect.
|
|
//
|
|
// Every case drives the real function that emits the message and asserts on
|
|
// what it returns. No production format string is restated here: a test that
|
|
// only re-rendered a copied format string would keep passing after the real
|
|
// message changed, which is exactly the regression these tests exist to
|
|
// catch.
|
|
|
|
// Full 40-hex fingerprints used where a message embeds one.
|
|
const (
|
|
msgFpA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
|
)
|
|
|
|
// runLocked runs fn while holding runMu, so operations that write to the
|
|
// process-global logger do not race the other CLI runs.
|
|
func runLocked(fn func() error) error {
|
|
runMu.Lock()
|
|
defer runMu.Unlock()
|
|
|
|
return fn()
|
|
}
|
|
|
|
// unsignedChecker builds a Checker over a freshly scanned, unsigned manifest.
|
|
func unsignedChecker(t *testing.T) *mfer.Checker {
|
|
t.Helper()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
|
|
|
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: fs})
|
|
require.NoError(t, s.EnumeratePath("/d", nil))
|
|
|
|
var buf bytes.Buffer
|
|
|
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
|
require.NoError(t, afero.WriteFile(fs, "/d/index.mf", buf.Bytes(), 0o644))
|
|
|
|
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
|
ManifestPath: "/d/index.mf",
|
|
BasePath: "/d",
|
|
Fs: fs,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, chk.IsSigned())
|
|
|
|
return chk
|
|
}
|
|
|
|
func TestNoManifestFoundMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := findManifest(afero.NewMemMapFs(), "/tmp/x")
|
|
require.ErrorIs(t, err, errNoManifestFound)
|
|
assert.EqualError(t, err,
|
|
"no manifest found in /tmp/x (looked for index.mf)")
|
|
}
|
|
|
|
func TestVerifyRequiredSignerMessages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("invalid fingerprint length", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := verifyRequiredSigner(context.Background(),
|
|
unsignedChecker(t), "12345678")
|
|
require.ErrorIs(t, err, errInvalidFingerprint)
|
|
assert.EqualError(t, err,
|
|
"invalid fingerprint: must be exactly 40 hex characters, got 8")
|
|
})
|
|
|
|
t.Run("manifest not signed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := verifyRequiredSigner(context.Background(),
|
|
unsignedChecker(t), msgFpA)
|
|
require.ErrorIs(t, err, errManifestNotSigned)
|
|
assert.EqualError(t, err,
|
|
"manifest is not signed, but signature from "+msgFpA+" is required")
|
|
})
|
|
}
|
|
|
|
// TestSignerMismatchMessage drives verifyRequiredSigner against a real signed
|
|
// manifest. The embedded fingerprint is whatever the generated key produced,
|
|
// so it is read back from the checker and substituted into the expected
|
|
// string; the required signer is a fixed value that cannot match it. Requires
|
|
// gpg and is skipped where it is absent, as the other signing tests are.
|
|
//
|
|
//nolint:paralleltest // signedManifest calls t.Setenv, which bars t.Parallel
|
|
func TestSignerMismatchMessage(t *testing.T) {
|
|
chk := signedChecker(t,
|
|
signedManifest(t, map[string][]byte{"f.txt": []byte("signed file")}))
|
|
|
|
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
err = verifyRequiredSigner(context.Background(), chk, msgFpB)
|
|
require.ErrorIs(t, err, errSignerMismatch)
|
|
assert.EqualError(t, err,
|
|
"embedded signing key fingerprint "+embeddedFP+
|
|
" does not match required "+msgFpB)
|
|
}
|
|
|
|
// signedManifest returns a manifest of files signed by a throwaway GPG key
|
|
// generated in a temporary GNUPGHOME, which it leaves set for the rest of
|
|
// the test.
|
|
func signedManifest(t *testing.T, files map[string][]byte) []byte {
|
|
t.Helper()
|
|
|
|
_, err := exec.LookPath("gpg")
|
|
if err != nil {
|
|
t.Skip("gpg not installed, skipping signing test")
|
|
}
|
|
|
|
gpgHome := t.TempDir()
|
|
params := "%no-protection\n" +
|
|
"Key-Type: RSA\nKey-Length: 2048\n" +
|
|
"Name-Real: MFER Test Key\nName-Email: test@mfer.test\n" +
|
|
"Expire-Date: 0\n%commit\n"
|
|
paramsFile := filepath.Join(gpgHome, "key-params")
|
|
require.NoError(t, os.WriteFile(paramsFile, []byte(params), 0o600))
|
|
|
|
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
|
cmd := exec.CommandContext(context.Background(), "gpg",
|
|
"--batch", "--gen-key", paramsFile)
|
|
|
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
|
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Skipf("failed to generate test GPG key: %v: %s", err, out)
|
|
}
|
|
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
b := mfer.NewBuilder()
|
|
b.SetSigningOptions(&mfer.SigningOptions{KeyID: mfer.GPGKeyID("test@mfer.test")})
|
|
|
|
for path, content := range files {
|
|
_, err = b.AddFile(mfer.RelFilePath(path), mfer.FileSize(len(content)),
|
|
mfer.ModTime{}, bytes.NewReader(content), nil)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
require.NoError(t, b.Build(context.Background(), &buf))
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// signedChecker builds a Checker over manifest, a signed manifest.
|
|
func signedChecker(t *testing.T, manifest []byte) *mfer.Checker {
|
|
t.Helper()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, afero.WriteFile(fs, "/index.mf", manifest, 0o644))
|
|
|
|
chk, err := mfer.NewChecker(&mfer.CheckerOptions{
|
|
ManifestPath: "/index.mf",
|
|
BasePath: "/",
|
|
Fs: fs,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, chk.IsSigned())
|
|
|
|
return chk
|
|
}
|
|
|
|
func TestPathDoesNotExistMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
cmd := &urfcli.Command{
|
|
Name: cmdGenerate,
|
|
Action: func(_ context.Context, c *urfcli.Command) error {
|
|
_, err := mfa.collectInputPaths(c.Args())
|
|
|
|
return err
|
|
},
|
|
}
|
|
|
|
err := cmd.Run(context.Background(), []string{cmdGenerate, "nope"})
|
|
require.ErrorIs(t, err, errPathNotExist)
|
|
assert.EqualError(t, err, "path does not exist: nope")
|
|
}
|
|
|
|
func TestOutputFileExistsMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/d", 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644))
|
|
require.NoError(t, afero.WriteFile(fs, "/out.mf", []byte("old"), 0o644))
|
|
|
|
mfa := &CLIApp{Fs: fs}
|
|
cmd := &urfcli.Command{
|
|
Name: cmdGenerate,
|
|
Flags: []urfcli.Flag{
|
|
&urfcli.StringFlag{Name: "output"},
|
|
&urfcli.BoolFlag{Name: "force"},
|
|
},
|
|
Action: mfa.generateManifestOperation,
|
|
}
|
|
|
|
// generateManifestOperation writes to the process-global logger during
|
|
// enumeration, so serialize with the other CLI runs.
|
|
err := runLocked(func() error {
|
|
return cmd.Run(context.Background(),
|
|
[]string{cmdGenerate, "--output", "/out.mf", "/d"})
|
|
})
|
|
require.ErrorIs(t, err, errOutputExists)
|
|
assert.EqualError(t, err,
|
|
"output file /out.mf already exists (use --force to overwrite)")
|
|
}
|
|
|
|
// TestUnknownCommandMessage drives the root command's action. run only logs
|
|
// the error that action returns, so the test lets run build the app with no
|
|
// command given and then runs that same app on an unknown command to get the
|
|
// error itself.
|
|
func TestUnknownCommandMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mfa := &CLIApp{
|
|
appname: testApp,
|
|
Stdout: &bytes.Buffer{},
|
|
Stderr: &bytes.Buffer{},
|
|
Fs: afero.NewMemMapFs(),
|
|
}
|
|
|
|
// run points the process-global logger at this app's output, so
|
|
// serialize with the other CLI runs.
|
|
err := runLocked(func() error {
|
|
mfa.run([]string{testApp})
|
|
|
|
return mfa.app.Run(context.Background(), []string{testApp, "bogus"})
|
|
})
|
|
require.ErrorIs(t, err, errUnknownCommand)
|
|
assert.EqualError(t, err, `unknown command "bogus"`)
|
|
}
|
|
|
|
func TestManifestLoaderHTTPStatusMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
|
|
_, err := mfa.openManifestReader(context.Background(), server.URL+"/foo.mf")
|
|
require.ErrorIs(t, err, errHTTPStatus)
|
|
assert.EqualError(t, err,
|
|
"failed to fetch "+server.URL+"/foo.mf: HTTP 404")
|
|
}
|
|
|
|
func TestFetchManifestHTTPStatusMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
|
|
cmd := mfa.fetchCommand()
|
|
cmd.Action = mfa.fetchManifestOperation
|
|
|
|
// fetchManifestOperation logs to the process-global logger.
|
|
err := runLocked(func() error {
|
|
return cmd.Run(context.Background(), []string{cmdFetch, server.URL})
|
|
})
|
|
require.ErrorIs(t, err, errHTTPStatus)
|
|
assert.EqualError(t, err, "failed to fetch manifest: HTTP 404")
|
|
}
|
|
|
|
func TestFetchFileHTTPStatusMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// downloadFile logs each retry of the 500 to the process-global logger.
|
|
err := runLocked(func() error {
|
|
return downloadFile(context.Background(), testClient(), server.URL+"/x", ".", "x",
|
|
&mfer.MFFilePath{}, nil)
|
|
})
|
|
require.ErrorIs(t, err, errHTTPStatus)
|
|
assert.EqualError(t, err, "HTTP 500")
|
|
}
|
|
|
|
func TestURLRequiredMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
cmd := &urfcli.Command{Name: cmdFetch, Action: mfa.fetchManifestOperation}
|
|
|
|
// fetchManifestOperation logs to the process-global logger.
|
|
err := runLocked(func() error {
|
|
return cmd.Run(context.Background(), []string{cmdFetch})
|
|
})
|
|
require.ErrorIs(t, err, errURLRequired)
|
|
assert.EqualError(t, err, "URL argument required")
|
|
}
|
|
|
|
func TestSanitizePathMessages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("empty", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := sanitizePath("")
|
|
require.ErrorIs(t, err, errEmptyPath)
|
|
assert.EqualError(t, err, "empty path")
|
|
})
|
|
|
|
t.Run("absolute", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := sanitizePath("/etc/passwd")
|
|
require.ErrorIs(t, err, errAbsolutePath)
|
|
assert.EqualError(t, err, "absolute path not allowed: /etc/passwd")
|
|
})
|
|
|
|
t.Run("traversal", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := sanitizePath("../x")
|
|
require.ErrorIs(t, err, errPathTraversal)
|
|
assert.EqualError(t, err, "path traversal not allowed: ../x")
|
|
})
|
|
}
|
|
|
|
func TestSizeMismatchMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// finishDownload returns the size-mismatch error before it touches the
|
|
// paths, digest, or entry, so those can be zero here.
|
|
err := finishDownload("", "", "", 9, 10, nil, nil, nil, nil)
|
|
require.ErrorIs(t, err, errSizeMismatch)
|
|
assert.EqualError(t, err, "size mismatch: expected 10 bytes, got 9")
|
|
}
|
|
|
|
func TestHashMismatchMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A 32-byte digest that matches none of the (empty) manifest hashes.
|
|
err := verifyDownloadedHash(make([]byte, 32), &mfer.MFFilePath{})
|
|
require.ErrorIs(t, err, errHashMismatch)
|
|
require.NotErrorIs(t, err, errSizeMismatch)
|
|
assert.EqualError(t, err, "hash mismatch")
|
|
}
|