Ctrl K
ComunidadGuías…

ClipmanCli/cmd/clipman-cli/main_test.go

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

Archivo original ↗Preguntar a KiBot
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)
	}
}