check / check (push) Waiting to run
errmsg_test.go now calls the function that emits each user-visible CLI error message and asserts on the full text it returns, instead of rendering format strings copied from production, so rewording any pinned message fails the suite. The unknown-command message is driven through the root command's action. The signer-mismatch case signs a manifest with a throwaway gpg key and is skipped where gpg is absent, like the repo's other signing tests. The freshen mtime-presence test gives the scanned file an epoch mtime, so it fails if recordEntry reads an absent manifest mtime as the epoch. Model: opus-4-8 (first implementation); opus-5-5 (completion, this message)
354 lines
10 KiB
Go
354 lines
10 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"flag"
|
|
"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/v2"
|
|
"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("/d/index.mf", "/d", 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 and .index.mf)")
|
|
}
|
|
|
|
func TestVerifyRequiredSignerMessages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("invalid fingerprint length", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := verifyRequiredSigner(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(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 // signedChecker calls t.Setenv, which bars t.Parallel
|
|
func TestSignerMismatchMessage(t *testing.T) {
|
|
chk := signedChecker(t)
|
|
|
|
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
|
|
require.NoError(t, err)
|
|
|
|
err = verifyRequiredSigner(chk, msgFpB)
|
|
require.ErrorIs(t, err, errSignerMismatch)
|
|
assert.EqualError(t, err,
|
|
"embedded signing key fingerprint "+embeddedFP+
|
|
" does not match required "+msgFpB)
|
|
}
|
|
|
|
// signedChecker builds a Checker over a manifest signed by a throwaway GPG
|
|
// key generated in a temporary GNUPGHOME.
|
|
func signedChecker(t *testing.T) *mfer.Checker {
|
|
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")})
|
|
|
|
content := []byte("signed file")
|
|
_, err = b.AddFile("f.txt", mfer.FileSize(len(content)), mfer.ModTime{},
|
|
bytes.NewReader(content), nil)
|
|
require.NoError(t, err)
|
|
|
|
var buf bytes.Buffer
|
|
|
|
require.NoError(t, b.Build(&buf))
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, afero.WriteFile(fs, "/index.mf", buf.Bytes(), 0o644))
|
|
|
|
chk, err := mfer.NewChecker("/index.mf", "/", fs)
|
|
require.NoError(t, err)
|
|
require.True(t, chk.IsSigned())
|
|
|
|
return chk
|
|
}
|
|
|
|
func TestPathDoesNotExistMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
set := flag.NewFlagSet("gen", flag.ContinueOnError)
|
|
require.NoError(t, set.Parse([]string{"nope"}))
|
|
|
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
ctx := urfcli.NewContext(nil, set, nil)
|
|
|
|
_, err := mfa.collectInputPaths(ctx.Args())
|
|
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))
|
|
|
|
set := flag.NewFlagSet("gen", flag.ContinueOnError)
|
|
set.String("output", "", "")
|
|
set.Bool("force", false, "")
|
|
require.NoError(t, set.Parse([]string{"/d"}))
|
|
require.NoError(t, set.Set("output", "/out.mf"))
|
|
|
|
mfa := &CLIApp{Fs: fs}
|
|
ctx := urfcli.NewContext(nil, set, nil)
|
|
|
|
// generateManifestOperation writes to the process-global logger during
|
|
// enumeration, so serialize with the other CLI runs.
|
|
err := runLocked(func() error { return mfa.generateManifestOperation(ctx) })
|
|
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([]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(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()
|
|
|
|
set := flag.NewFlagSet("fetch", flag.ContinueOnError)
|
|
require.NoError(t, set.Parse([]string{server.URL}))
|
|
|
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
ctx := urfcli.NewContext(nil, set, nil)
|
|
|
|
// fetchManifestOperation logs to the process-global logger.
|
|
err := runLocked(func() error { return mfa.fetchManifestOperation(ctx) })
|
|
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()
|
|
|
|
err := downloadFile(context.Background(), 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()
|
|
|
|
set := flag.NewFlagSet("fetch", flag.ContinueOnError)
|
|
require.NoError(t, set.Parse([]string{}))
|
|
|
|
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
|
|
ctx := urfcli.NewContext(nil, set, nil)
|
|
|
|
// fetchManifestOperation logs to the process-global logger.
|
|
err := runLocked(func() error { return mfa.fetchManifestOperation(ctx) })
|
|
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")
|
|
}
|