ClipmanCli/cmd/clipman-cli/main_test.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"flag"
"fmt"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/clipdb"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/model"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/server"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/config"
"github.com/OnjLouis/Clipman/ClipmanCli/internal/operation"
)
func testServerWithCertificateChain(t *testing.T) (*httptest.Server, *x509.Certificate, *x509.Certificate) {
t.Helper()
now := time.Now()
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Clipman Test Authority"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
ca, err := x509.ParseCertificate(caDER)
if err != nil {
t.Fatal(err)
}
leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
leafTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "127.0.0.1"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
}
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, ca, &leafKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
leaf, err := x509.ParseCertificate(leafDER)
if err != nil {
t.Fatal(err)
}
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
server.TLS = &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{leafDER, caDER}, PrivateKey: leafKey}}, MinVersion: tls.VersionTLS12}
server.StartTLS()
t.Cleanup(server.Close)
return server, leaf, ca
}
func TestGlobalParsingStopsAtCommand(t *testing.T) {
g, remaining, err := parseGlobals([]string{"--server", "clipman://host:60000", "put", "--text", "--json"})
if err != nil {
t.Fatal(err)
}
if g.server != "clipman://host:60000" {
t.Fatalf("server = %q", g.server)
}
if len(remaining) != 3 || remaining[0] != "put" || remaining[1] != "--text" || remaining[2] != "--json" {
t.Fatalf("remaining = %#v", remaining)
}
}
func TestSourceVersionMatchesVersionFile(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "VERSION"))
if err != nil {
t.Fatal(err)
}
if want := strings.TrimSpace(string(data)); version != want {
t.Fatalf("source version = %q, VERSION = %q", version, want)
}
}
func TestCommandHelpDoesNotStealOptionValues(t *testing.T) {
if hasHelpOption([]string{"--text", "--help"}) {
t.Fatal("--help used as an option value must not trigger command help")
}
if !hasHelpOption([]string{"--help"}) {
t.Fatal("leading --help should trigger command help")
}
}
func TestUnknownGlobalOptionFails(t *testing.T) {
if _, _, err := parseGlobals([]string{"--unknown", "list"}); err == nil {
t.Fatal("expected unknown global option error")
}
}
func TestHelpAndUnknownCommandDoNotNeedConfiguration(t *testing.T) {
if code := run([]string{"help"}); code != 0 {
t.Fatalf("help exit code = %d", code)
}
if code := run([]string{"help", "get"}); code != 0 {
t.Fatalf("help get exit code = %d", code)
}
if code := run([]string{"not-a-command"}); code != 2 {
t.Fatalf("unknown command exit code = %d", code)
}
}
func TestResolvePasswordRequiresNonblankValue(t *testing.T) {
if _, err := resolvePassword(globals{password: optionalString{set: true}}, config.Default(), false); err == nil || !strings.Contains(err.Error(), "nonblank history password") {
t.Fatalf("blank history password error = %v", err)
}
password, err := resolvePassword(globals{password: optionalString{set: true, value: "test-password"}}, config.Default(), false)
if err != nil || password != "test-password" {
t.Fatalf("nonblank history password = %q, %v", password, err)
}
}
// captureStdout runs body with standard output redirected and returns what it
// wrote, so usage tests do not pollute the test log.
func captureStdout(t *testing.T, body func()) string {
t.Helper()
read, write, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
saved := os.Stdout
os.Stdout = write
done := make(chan string, 1)
go func() {
data, _ := io.ReadAll(read)
done <- string(data)
}()
body()
os.Stdout = saved
write.Close()
output := <-done
read.Close()
return output
}
// selectorFlags mirrors the option shapes that get and rm register: a mix of
// options that take a value and options that do not.
func selectorFlags() *flag.FlagSet {
fs := newFlagSet("get")
fs.String("id", "", "exact entry ID")
fs.String("kind", "history", "history, templates, or all")
fs.Bool("json", false, "write JSON output")
fs.Bool("touch", false, "mark the entry used")
return fs
}
func TestPermuteArgsAllowsOptionsAfterTheIndex(t *testing.T) {
cases := []struct {
name string
args []string
want []string
}{
{"index then boolean option", []string{"0", "--json"}, []string{"--json", "--", "0"}},
{"index then option with a value", []string{"0", "--kind", "all"}, []string{"--kind", "all", "--", "0"}},
{"index between options", []string{"--touch", "3", "--json"}, []string{"--touch", "--json", "--", "3"}},
{"already in flag order", []string{"--json", "0"}, []string{"--json", "--", "0"}},
{"joined option value", []string{"0", "--kind=all"}, []string{"--kind=all", "--", "0"}},
{"no operands", []string{"--id", "abc", "--json"}, []string{"--id", "abc", "--json"}},
{"explicit separator is preserved", []string{"--json", "--", "0"}, []string{"--json", "--", "0"}},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
got := permuteArgs(selectorFlags(), testCase.args)
if strings.Join(got, " ") != strings.Join(testCase.want, " ") {
t.Fatalf("permuteArgs(%q) = %q, want %q", testCase.args, got, testCase.want)
}
})
}
}
// permuteArgs must not swallow the value of an option, which would turn
// `--id 0` into a selector with an empty ID and a stray index.
func TestPermuteArgsKeepsOptionValuesAttached(t *testing.T) {
got := permuteArgs(selectorFlags(), []string{"--id", "0"})
if strings.Join(got, " ") != "--id 0" {
t.Fatalf("permuteArgs = %q, want %q", got, "--id 0")
}
}
// An undefined option keeps reaching Parse so that it is still reported as a
// usage error rather than silently treated as an index.
func TestPermuteArgsLeavesUndefinedOptionsForParse(t *testing.T) {
got := permuteArgs(selectorFlags(), []string{"--nope"})
if strings.Join(got, " ") != "--nope" {
t.Fatalf("permuteArgs = %q, want %q", got, "--nope")
}
}
func TestParseGetAcceptsOptionsAfterTheIndex(t *testing.T) {
var g globals
selector, kind, _, touch, _, _, err := parseGet([]string{"0", "--json", "--touch", "--kind", "all"}, config.Default(), &g)
if err != nil {
t.Fatalf("parseGet returned %v", err)
}
if selector.Index == nil || *selector.Index != 0 {
t.Fatalf("selector index = %v, want 0", selector.Index)
}
if kind != operation.All {
t.Fatalf("kind = %q, want %q", kind, operation.All)
}
if !touch {
t.Fatal("--touch after the index was not applied")
}
if !g.json {
t.Fatal("--json after the index was not applied")
}
}
func TestBuildSelectorRejectsNegativeIndex(t *testing.T) {
_, err := buildSelector([]string{"-1"}, "", "", "", false, false)
if err == nil || !strings.Contains(err.Error(), "non-negative") {
t.Fatalf("negative index error = %v", err)
}
var app appError
if !errors.As(err, &app) || app.code != 2 {
t.Fatalf("negative index exit code = %v, want 2", err)
}
}
func TestBuildSelectorRejectsCombinedSelectors(t *testing.T) {
if _, err := buildSelector([]string{"0"}, "some-id", "", "", false, false); err == nil {
t.Fatal("index combined with --id should fail")
}
if _, err := buildSelector([]string{"0", "1"}, "", "", "", false, false); err == nil {
t.Fatal("two indexes should fail")
}
}
func TestCommandHelpWorksAfterOtherOptions(t *testing.T) {
fs := selectorFlags()
var err error
output := captureStdout(t, func() {
err = parseCommandFlags(fs, "get", []string{"--json", "--help"})
})
if !errors.Is(err, errHelpRequested) {
t.Fatalf("parseCommandFlags returned %v, want errHelpRequested", err)
}
if !strings.Contains(output, "Usage: clipman-cli") {
t.Fatalf("help output = %q", output)
}
if printError(err) != 0 {
t.Fatal("requesting help must exit 0")
}
}
func TestEveryKnownCommandHasUsageText(t *testing.T) {
for _, command := range []string{"init", "status", "list", "get", "put", "rm", "sync", "pick", "menu", "help"} {
if !printCommandUsage(io.Discard, command) {
t.Fatalf("command %q has no usage text", command)
}
}
if printCommandUsage(io.Discard, "not-a-command") {
t.Fatal("unknown command reported usage text")
}
}
func captureStderr(t *testing.T, body func()) string {
t.Helper()
read, write, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
saved := os.Stderr
os.Stderr = write
done := make(chan string, 1)
go func() {
data, _ := io.ReadAll(read)
done <- string(data)
}()
body()
os.Stderr = saved
write.Close()
output := <-done
read.Close()
return output
}
func TestVerboseDiagnosticsRequireTheOption(t *testing.T) {
quiet := captureStderr(t, func() { verbosef(globals{}, "server %s", "https://example.invalid") })
if quiet != "" {
t.Fatalf("diagnostics must stay off without --verbose, got %q", quiet)
}
suppressed := captureStderr(t, func() {
verbosef(globals{verbose: true, quiet: true}, "server %s", "https://example.invalid")
})
if suppressed != "" {
t.Fatalf("--quiet must win over --verbose, got %q", suppressed)
}
enabled := captureStderr(t, func() {
verbosef(globals{verbose: true}, "server %s", "https://example.invalid")
})
if !strings.Contains(enabled, "server https://example.invalid") {
t.Fatalf("verbose output = %q", enabled)
}
}
func TestCertificateTrustPromptOnlyHandlesUnknownAuthority(t *testing.T) {
unknown := x509.UnknownAuthorityError{}
if !isCertificateTrustError(unknown) {
t.Fatal("unknown authority should offer the trust prompt")
}
wrapped := &tls.CertificateVerificationError{Err: unknown}
if !isCertificateTrustError(wrapped) {
t.Fatal("wrapped unknown authority should offer the trust prompt")
}
if isCertificateTrustError(x509.HostnameError{}) {
t.Fatal("hostname mismatch must not offer a trust prompt")
}
if isCertificateTrustError(x509.CertificateInvalidError{}) {
t.Fatal("expired or otherwise invalid certificate must not offer a trust prompt")
}
}
func TestFetchServerCertificatesUsesAuthorityFingerprint(t *testing.T) {
server, wantLeaf, wantAuthority := testServerWithCertificateChain(t)
leaf, authority, err := fetchServerCertificates(context.Background(), server.URL)
if err != nil {
t.Fatal(err)
}
if !leaf.Equal(wantLeaf) {
t.Fatalf("leaf certificate = %q, want %q", leaf.Subject, wantLeaf.Subject)
}
if !authority.Equal(wantAuthority) {
t.Fatalf("authority certificate = %q, want %q", authority.Subject, wantAuthority.Subject)
}
if certificateFingerprintSHA256(authority) == certificateFingerprintSHA256(leaf) {
t.Fatal("authority and leaf fingerprints unexpectedly match")
}
}
// TestWhereToSaveIsNotAskedWhenTheLocationIsSettled covers the cases where init
// must stay quiet. Asking after the user has already named a location invites
// them to contradict themselves, and a non-interactive run has nobody to ask.
func TestWhereToSaveIsNotAskedWhenTheLocationIsSettled(t *testing.T) {
t.Setenv("CLIPMAN_CONFIG", "")
t.Setenv("CLIPMAN_HOME", "")
os.Unsetenv("CLIPMAN_CONFIG")
os.Unsetenv("CLIPMAN_HOME")
if shouldAskWhereToSave(globals{}, true) {
t.Error("a non-interactive run must not ask")
}
if shouldAskWhereToSave(globals{configPath: "some.toml"}, false) {
t.Error("--config already names the location, so init must not ask")
}
t.Setenv("CLIPMAN_CONFIG", "from-environment.toml")
if shouldAskWhereToSave(globals{}, false) {
t.Error("CLIPMAN_CONFIG already names the location, so init must not ask")
}
os.Unsetenv("CLIPMAN_CONFIG")
t.Setenv("CLIPMAN_HOME", "somewhere")
if shouldAskWhereToSave(globals{}, false) {
t.Error("CLIPMAN_HOME already names the location, so init must not ask")
}
}
// TestInitHelpMentionsWhereSettingsGo keeps the built-in help honest about the
// choice, since the question only appears on an interactive run.
func TestInitHelpMentionsWhereSettingsGo(t *testing.T) {
var out strings.Builder
if !printCommandUsage(&out, "init") {
t.Fatal("init has no usage text")
}
if !strings.Contains(out.String(), "--portable") {
t.Fatalf("init help does not mention --portable:\n%s", out.String())
}
}
// TestHandingOverAvoidsASilentDownload is the guard for the gap in a switch.
// The screen is torn down before the new interface draws anything, and a
// download plus a PBKDF2 decrypt in that gap is silence with nothing to explain
// it. The store therefore hands the already-loaded database to the interface
// being started.
//
// ctx is deliberately nil: reaching the network path would dereference it, so
// this test fails loudly rather than passing while quietly downloading.
func TestHandingOverAvoidsASilentDownload(t *testing.T) {
database := model.NewDatabase(1)
database.Entries = []model.Entry{{ID: "a", Text: "carried across the switch"}}
store := &cliStore{kind: operation.History}
store.last = &database
store.handOver()
entries, err := store.Load(context.Background())
if err != nil {
t.Fatalf("a handed-over load must not fail: %v", err)
}
if len(entries) != 1 || entries[0].ID != "a" {
t.Fatalf("the handed-over database must be what is shown, got %#v", entries)
}
// One shot only, so an explicit reload still asks the server rather than
// replaying what the user already had.
if store.reuse != nil {
t.Fatal("the handover must be consumed once, or r would stop reloading")
}
}
// TestHandingOverRebuildsTheViewForTheNewKind covers Tab. The full-screen
// interface can change the kind and the line interface cannot, so the view has
// to be re-derived on arrival rather than replayed wholesale.
func TestHandingOverRebuildsTheViewForTheNewKind(t *testing.T) {
database := model.NewDatabase(1)
database.Entries = []model.Entry{
{ID: "plain", Text: "an ordinary clip"},
{ID: "tmpl", Text: "a template", IsTemplate: true},
}
store := &cliStore{kind: operation.History}
store.last = &database
store.SetKind(operation.Templates)
store.handOver()
entries, err := store.Load(context.Background())
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(entries) != 1 || entries[0].ID != "tmpl" {
t.Fatalf("the handed-over view must be rebuilt for the kind now in force, got %#v", entries)
}
}
// TestRuntimeErrorsGetTheRightExitCode pins section 7.2. The fallthrough used to
// be 4, so a local encode or decode failure that surfaced through a call which
// also touches the network reported itself as a network failure — telling a
// script to retry something retrying cannot fix, and telling a user the network
// was at fault when it was not.
func TestRuntimeErrorsGetTheRightExitCode(t *testing.T) {
for _, item := range []struct {
name string
err error
want int
}{
{"local decode failure", errors.New("trailing garbage after JSON value"), 1},
{"unauthorized", server.ErrUnauthorized, 3},
{"not found", server.ErrNotFound, 6},
{"password required", clipdb.ErrPasswordRequired, 5},
{"password or data", clipdb.ErrPasswordOrData, 5},
{"timeout", &net.DNSError{Err: "timed out", IsTimeout: true}, 4},
{"connection refused", &net.OpError{Op: "dial", Err: errors.New("connection refused")}, 4},
{"server error", &server.StatusError{StatusCode: 503, Body: "unavailable"}, 4},
{"payload too large", &server.StatusError{StatusCode: 413, Body: "too big"}, 4},
// A 4xx that is not one of the sentinels is the request's problem, not
// the network's, so it must not claim a network exit code.
{"unexpected client status", &server.StatusError{StatusCode: 418, Body: "teapot"}, 1},
} {
t.Run(item.name, func(t *testing.T) {
var app appError
err := mapRuntimeError("operation failed", item.err)
if !errors.As(err, &app) {
t.Fatalf("mapRuntimeError must return an appError, got %T", err)
}
if app.code != item.want {
t.Errorf("exit code = %d, want %d, for %v", app.code, item.want, item.err)
}
})
}
}
// TestWrappedRuntimeErrorsAreStillClassified checks the classification survives
// wrapping, which is how these errors actually arrive from the sync engine.
func TestWrappedRuntimeErrorsAreStillClassified(t *testing.T) {
wrapped := fmt.Errorf("upload failed: %w", &server.StatusError{StatusCode: 500, Body: "boom"})
var app appError
if err := mapRuntimeError("sync", wrapped); !errors.As(err, &app) || app.code != 4 {
t.Fatalf("a wrapped server fault must still be 4, got %v", err)
}
wrappedLocal := fmt.Errorf("merge failed: %w", errors.New("duplicate entry id"))
if err := mapRuntimeError("sync", wrappedLocal); !errors.As(err, &app) || app.code != 1 {
t.Fatalf("a wrapped local failure must be 1, got %v", err)
}
}