package cli //nolint:testpackage // shares runEntry and the argument constants import ( "fmt" "net/http" "net/http/httptest" "os" "os/signal" "path/filepath" "strings" "testing" "time" "github.com/adrg/xdg" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // stalledStoreConfig is hermeticConfig with an s3:// destination store // in place of the file:// one. The server behind it accepts any // credentials. const stalledStoreConfig = `age_recipients: - age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj snapshots: test: paths: - %s storage_url: s3://bucket?endpoint=%s&ssl=false s3: access_key_id: key secret_access_key: secret index_path: %s hostname: test-host ` // interruptRepeat is how often interruptOnFirstRequest sends SIGINT. const interruptRepeat = 50 * time.Millisecond // TestEntryInterruptedRun sends SIGINT to the test process while a // command waits on the destination store, and checks that Entry returns // 130 and prints one line on stderr saying the run was interrupted. The // store is a local HTTP server that holds every request open, so the // command is always mid-operation when the signal arrives. The two // cases cover --cron and --json, which silence other output. // // Not parallel: it signals the process and replaces os.Args, os.Stdout, // os.Stderr and the xdg globals. // //nolint:paralleltest // signals the process and replaces process globals func TestEntryInterruptedRun(t *testing.T) { for _, testCase := range []struct { name string args []string }{ { name: "snapshot create --cron", args: []string{cmdSnapshot, cmdCreate, "--cron"}, }, { name: "snapshot verify --json", args: []string{cmdSnapshot, cmdVerify, someSnapshotID, flagJSON}, }, } { t.Run(testCase.name, func(t *testing.T) { endpoint, requestArrived := startStalledStore(t) configPath := writeStalledStoreConfig(t, endpoint) interruptOnFirstRequest(t, requestArrived) code, _, stderr := runEntry(t, append([]string{flagConfig, configPath}, testCase.args...)...) assert.Equal(t, 130, code) assert.Equal(t, 1, strings.Count(stderr, errInterrupted.Error()), stderr) }) } } // interruptOnFirstRequest sends SIGINT to the test process every // interruptRepeat, from the first request to the destination store until // the test ends. One signal is not enough: the command can reach the // store before fx has started catching signals. The test catches SIGINT // too, so that a signal fx is not catching does not kill the test // binary. func interruptOnFirstRequest(t *testing.T, requestArrived <-chan struct{}) { t.Helper() self, err := os.FindProcess(os.Getpid()) require.NoError(t, err) caught := make(chan os.Signal, 1) signal.Notify(caught, os.Interrupt) testEnded := make(chan struct{}) senderDone := make(chan struct{}) // Stop catching SIGINT only after the sender has returned. The sender // waits for each SIGINT it sends to arrive on caught; one still on // its way after signal.Stop would kill the test binary. t.Cleanup(func() { close(testEnded) <-senderDone signal.Stop(caught) }) go func() { defer close(senderDone) select { case <-requestArrived: case <-testEnded: return } ticker := time.NewTicker(interruptRepeat) defer ticker.Stop() for { // Empty caught, so that the receive below waits for this // SIGINT rather than an earlier one. select { case <-caught: default: } sendErr := self.Signal(os.Interrupt) if sendErr != nil { t.Errorf("sending SIGINT: %v", sendErr) return } <-caught select { case <-testEnded: return case <-ticker.C: } } }() } // startStalledStore starts an HTTP server that never answers: each // request is held until the client gives up on it or the test ends. // It returns the server's host:port and a channel that receives a value // when the first request arrives. func startStalledStore(t *testing.T) (string, <-chan struct{}) { t.Helper() requestArrived := make(chan struct{}, 1) release := make(chan struct{}) server := httptest.NewServer(http.HandlerFunc( func(_ http.ResponseWriter, r *http.Request) { select { case requestArrived <- struct{}{}: default: } select { case <-r.Context().Done(): case <-release: } })) // Cleanups run last-registered first, so release lets any held // request return before Close waits for it. t.Cleanup(server.Close) t.Cleanup(func() { close(release) }) return server.Listener.Addr().String(), requestArrived } // writeStalledStoreConfig writes a config whose destination store is the // server at endpoint and whose snapshot source holds one small file, so // that `snapshot create` has a blob to upload. Returns the config path. func writeStalledStoreConfig(t *testing.T, endpoint string) string { t.Helper() dir := t.TempDir() configPath := filepath.Join(dir, "config.yml") sourceDir := filepath.Join(dir, "source") require.NoError(t, os.Mkdir(sourceDir, 0o750)) require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "file.txt"), []byte("contents"), 0o600)) contents := fmt.Sprintf(stalledStoreConfig, sourceDir, endpoint, filepath.Join(dir, "index.sqlite")) require.NoError(t, os.WriteFile(configPath, []byte(contents), configFileMode)) // The PID lock lives under xdg.DataHome, which xdg resolves at // package init; point it at the temp dir so the test neither // touches nor collides with the real one. t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data")) xdg.Reload() t.Cleanup(xdg.Reload) return configPath }