check / check (push) Successful in 2m31s
The banner is printed to stdout before cobra parses, and bannerSuppressedInArgs recognised only --quiet, -q and --cron. So every --json document was preceded by two banner lines and a blank one, and `vaultik snapshot list --json | jq` failed. Passing opts.JSON as extraQuiet did not help: that calls UI.SetQuiet in an fx OnStart hook, long after Entry has printed. The raw-argv scan is extended rather than the banner moved after parsing. root.go documents that the banner must survive cobra rejecting its arguments and --help, and no single post-parse location covers those paths. The subcommand-versus-persistent distinction does not decide it: --cron is already in the suppression list and is itself subcommand-only, existing on snapshot create alone, so this adds another instance of an accepted imprecision rather than a new kind. The error directions are asymmetric - a false positive loses a decorative banner, a false negative corrupts a document - so the scan errs toward suppression, which is also why --json=false suppresses, exactly as --quiet=false already does. Four of the five --json commands now pipe into jq cleanly with no other flags: snapshot list, snapshot verify, snapshot remove, remote info. prune does not, because pruneLocalSnapshots writes three prose lines to stdout with no --json awareness. That reproduces identically before this change and -q never suppressed it either, since printlnStdout and stdoutf bypass v.UI entirely. Tracked as #108. Also fixed: TTYHandler's human-readable byte formatting did not survive grouping, because the key check compared against the bare attribute name and a grouped record presents it qualified. AGENTS.md policy 9 keyed the log format on stdout's TTY-ness, which #82 made false by moving the logger to stderr; it now names the log stream. Vaultik.Stderr keeps its field with the comment amended to say outright that nothing writes to it, and the dead listEnv.stderr is removed.
861 lines
27 KiB
Go
861 lines
27 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// errRemoteUnreachable stands in for the real-world reasons a
|
|
// destination store cannot be listed: unmounted volume, permission
|
|
// denied, network down.
|
|
var errRemoteUnreachable = errors.New("permission denied")
|
|
|
|
// observingStorer wraps testStorer to record how the destination store
|
|
// was used: how many prefix listings were issued (the merged listing
|
|
// must not scale requests with snapshot count) and which object keys
|
|
// were fetched (nothing encrypted may be fetched during a listing).
|
|
// Setting listErr makes every listing fail, simulating an unreachable
|
|
// destination.
|
|
type observingStorer struct {
|
|
*testStorer
|
|
|
|
mu sync.Mutex
|
|
listCalls int
|
|
fetched []string
|
|
listErr error
|
|
}
|
|
|
|
func newObservingStorer() *observingStorer {
|
|
return &observingStorer{testStorer: newTestStorer()}
|
|
}
|
|
|
|
func (s *observingStorer) ListStream(
|
|
ctx context.Context, prefix string,
|
|
) <-chan storage.ObjectInfo {
|
|
s.mu.Lock()
|
|
s.listCalls++
|
|
failure := s.listErr
|
|
s.mu.Unlock()
|
|
|
|
if failure != nil {
|
|
ch := make(chan storage.ObjectInfo, 1)
|
|
ch <- storage.ObjectInfo{Err: failure}
|
|
|
|
close(ch)
|
|
|
|
return ch
|
|
}
|
|
|
|
return s.testStorer.ListStream(ctx, prefix)
|
|
}
|
|
|
|
func (s *observingStorer) Get(
|
|
ctx context.Context, key string,
|
|
) (io.ReadCloser, error) {
|
|
s.mu.Lock()
|
|
s.fetched = append(s.fetched, key)
|
|
s.mu.Unlock()
|
|
|
|
return s.testStorer.Get(ctx, key)
|
|
}
|
|
|
|
// listStreamCalls returns how many prefix listings were issued.
|
|
func (s *observingStorer) listStreamCalls() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return s.listCalls
|
|
}
|
|
|
|
// fetchedKeys returns a copy of every object key that was read.
|
|
func (s *observingStorer) fetchedKeys() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return append([]string(nil), s.fetched...)
|
|
}
|
|
|
|
// listEnv is a Vaultik wired for exercising ListSnapshots: an in-memory
|
|
// index database, an observable in-memory destination store, and
|
|
// captured output.
|
|
//
|
|
// The configuration deliberately has no age secret key. That is the
|
|
// production configuration vaultik is designed for — the backed-up host
|
|
// holds only the public key — and every assertion in this file has to
|
|
// hold in it.
|
|
type listEnv struct {
|
|
v *vaultik.Vaultik
|
|
store *observingStorer
|
|
stdout *bytes.Buffer
|
|
}
|
|
|
|
func newListEnv(t *testing.T) *listEnv {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
|
|
db, err := database.New(ctx, ":memory:")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
store := newObservingStorer()
|
|
stdout := &bytes.Buffer{}
|
|
|
|
v := &vaultik.Vaultik{
|
|
Config: &config.Config{
|
|
AgeSecretKey: "",
|
|
Snapshots: map[string]config.SnapshotConfig{
|
|
listConfiguredName: {Paths: []string{"/" + listConfiguredName}},
|
|
},
|
|
},
|
|
Storage: store,
|
|
Repositories: database.NewRepositories(db),
|
|
DB: db,
|
|
Stdout: stdout,
|
|
Stderr: &bytes.Buffer{},
|
|
Stdin: &bytes.Buffer{},
|
|
UI: ui.NewWithColor(stdout, false),
|
|
}
|
|
v.SetContext(ctx)
|
|
|
|
return &listEnv{v: v, store: store, stdout: stdout}
|
|
}
|
|
|
|
// addLocal inserts a completed snapshot into the local index.
|
|
func (e *listEnv) addLocal(t *testing.T, id string, startedAt time.Time) {
|
|
t.Helper()
|
|
|
|
completedAt := startedAt.Add(time.Minute)
|
|
snap := &database.Snapshot{
|
|
ID: types.SnapshotID(id),
|
|
Hostname: "testhost",
|
|
VaultikVersion: testLabel,
|
|
StartedAt: startedAt,
|
|
CompletedAt: &completedAt,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
err := e.v.Repositories.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
return e.v.Repositories.Snapshots.Create(ctx, tx, snap)
|
|
})
|
|
require.NoError(t, err, "creating local snapshot %s", id)
|
|
}
|
|
|
|
// addRemote writes a manifest to the destination store at the hashed
|
|
// path the production code uses, exactly as a real backup would. Every
|
|
// fixture snapshot has the same compressed size (fiveMegabytes); the
|
|
// tests care about which columns are populated, not about size variety.
|
|
func (e *listEnv) addRemote(
|
|
t *testing.T, snapshotID string, timestamp time.Time,
|
|
) string {
|
|
t.Helper()
|
|
|
|
return e.addRemoteRawTimestamp(t, snapshotID,
|
|
timestamp.UTC().Format(time.RFC3339))
|
|
}
|
|
|
|
// addRemoteRawTimestamp is addRemote with the manifest's timestamp field
|
|
// written verbatim, so the unparseable-timestamp path can be exercised
|
|
// with a value no time.Parse will accept.
|
|
func (e *listEnv) addRemoteRawTimestamp(
|
|
t *testing.T, snapshotID, timestamp string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
|
manifest := &snapshot.Manifest{
|
|
// Note: the hashed key, never the human ID. That is precisely
|
|
// why a remote-only snapshot cannot be named.
|
|
SnapshotID: remoteKey,
|
|
Timestamp: timestamp,
|
|
BlobCount: 1,
|
|
TotalCompressedSize: fiveMegabytes,
|
|
Blobs: []snapshot.BlobInfo{
|
|
{Hash: testBlobHashA, CompressedSize: fiveMegabytes},
|
|
},
|
|
}
|
|
|
|
data, err := snapshot.EncodeManifest(manifest, 3)
|
|
require.NoError(t, err)
|
|
|
|
err = e.store.Put(context.Background(),
|
|
"metadata/"+remoteKey+"/manifest.json.zst", bytes.NewReader(data))
|
|
require.NoError(t, err)
|
|
|
|
return remoteKey
|
|
}
|
|
|
|
// Fixtures shared across the listing tests.
|
|
const (
|
|
// listConfiguredName is the one snapshot name in the test config.
|
|
listConfiguredName = "home"
|
|
listLocalID = "testhost_home_2026-03-01T10:00:00Z"
|
|
listRemoteID = "otherhost_media_2026-03-02T11:22:33Z"
|
|
// fiveMegabytes formats as "5.0 MB" through formatBytes.
|
|
fiveMegabytes = 5 * 1024 * 1024
|
|
)
|
|
|
|
// TestListSnapshots_RemoteWithoutSecretKey is the regression guard for
|
|
// issue #64: `snapshot list` must read the destination store on a host
|
|
// that holds no private key. If the remote listing is ever gated on
|
|
// age_secret_key again, this fails.
|
|
func TestListSnapshots_RemoteWithoutSecretKey(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newListEnv(t)
|
|
require.Empty(t, env.v.Config.AgeSecretKey,
|
|
"this test is meaningless unless the host has no private key")
|
|
|
|
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
|
|
remoteKey := env.addRemote(t, listRemoteID, timestamp)
|
|
|
|
err := env.v.ListSnapshots(false)
|
|
require.NoError(t, err)
|
|
|
|
// The destination store was actually read, with a single prefix
|
|
// listing rather than one request per snapshot.
|
|
assert.Equal(t, 1, env.store.listStreamCalls(),
|
|
"expected exactly one prefix listing of the destination store")
|
|
|
|
// Nothing encrypted was touched: enumerating snapshots must never
|
|
// need the age secret key.
|
|
for _, key := range env.store.fetchedKeys() {
|
|
assert.NotContains(t, key, ".age",
|
|
"listing must not read encrypted objects")
|
|
}
|
|
|
|
out := env.stdout.String()
|
|
|
|
// The snapshot is identified by an abbreviation of its remote key.
|
|
assert.Contains(t, out, "<remote only:"+remoteKey[:12]+">")
|
|
|
|
// Its human ID is not recoverable and must not be invented.
|
|
assert.NotContains(t, out, "otherhost")
|
|
assert.NotContains(t, out, "media")
|
|
|
|
// Manifest-derived columns carry real values.
|
|
assert.Contains(t, out, "2026-03-02 11:22:33")
|
|
assert.Contains(t, out, "5.0 MB")
|
|
|
|
// The two columns that require the local index are marked, not
|
|
// blank and not zero. ("<remote only:" does not match this needle,
|
|
// so the count is exactly the two marker cells.)
|
|
assert.Equal(t, 2, strings.Count(out, remoteOnlyCellText),
|
|
"expected the uncompressed and new-chunk cells to be marked")
|
|
}
|
|
|
|
// remoteOnlyCellText is the marker the table puts in columns that can
|
|
// only be computed from the local index.
|
|
const remoteOnlyCellText = "<remote only>"
|
|
|
|
// TestListSnapshots_RemoteOnlyRowRendering pins the exact row a
|
|
// remote-only snapshot produces, so the "<remote only>" cells and the
|
|
// LocallyTracked == false branch are verified rather than assumed.
|
|
func TestListSnapshots_RemoteOnlyRowRendering(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newListEnv(t)
|
|
|
|
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
|
|
remoteKey := env.addRemote(t, listRemoteID, timestamp)
|
|
|
|
err := env.v.ListSnapshots(false)
|
|
require.NoError(t, err)
|
|
|
|
label := "<remote only:" + remoteKey[:12] + ">"
|
|
row := findTableRow(t, env.stdout.String(), label)
|
|
|
|
// Identifier column: the abbreviated remote key, never blank and
|
|
// visibly not a snapshot name.
|
|
assert.True(t, strings.HasPrefix(row, label),
|
|
"identifier column must lead the row: %q", row)
|
|
|
|
// Manifest-derived columns: real values, not placeholders.
|
|
assert.Contains(t, row, "2026-03-02 11:22:33")
|
|
assert.Contains(t, row, "5.0 MB")
|
|
|
|
// Exactly the two local-index-derived columns are marked.
|
|
assert.Equal(t, 2, strings.Count(row, remoteOnlyCellText),
|
|
"uncompressed and new-chunk cells must both be marked: %q", row)
|
|
|
|
// And the note explaining why the row has no name.
|
|
assert.Contains(t, env.stdout.String(),
|
|
"are not in the local index")
|
|
}
|
|
|
|
// findTableRow returns the single output line containing needle.
|
|
func findTableRow(t *testing.T, out, needle string) string {
|
|
t.Helper()
|
|
|
|
var found []string
|
|
|
|
for line := range strings.SplitSeq(out, "\n") {
|
|
if strings.Contains(line, needle) {
|
|
found = append(found, line)
|
|
}
|
|
}
|
|
|
|
require.Len(t, found, 1, "expected exactly one line containing %q", needle)
|
|
|
|
return found[0]
|
|
}
|
|
|
|
// TestListSnapshots_MergesLocalAndRemote checks that both sources land
|
|
// in one table and that a locally tracked snapshot keeps its human ID
|
|
// and its local-index-derived columns.
|
|
func TestListSnapshots_MergesLocalAndRemote(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newListEnv(t)
|
|
|
|
localStart := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
|
|
env.addLocal(t, listLocalID, localStart)
|
|
env.addRemote(t, listLocalID, localStart)
|
|
|
|
remoteKey := env.addRemote(t, listRemoteID,
|
|
time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC))
|
|
|
|
err := env.v.ListSnapshots(false)
|
|
require.NoError(t, err)
|
|
|
|
out := env.stdout.String()
|
|
|
|
assert.Contains(t, out, listLocalID)
|
|
assert.Contains(t, out, "<remote only:"+remoteKey[:12]+">")
|
|
|
|
// The locally tracked row is not marked as remote-only anywhere.
|
|
localRow := findTableRow(t, out, listLocalID)
|
|
assert.NotContains(t, localRow, "<remote only>")
|
|
|
|
// The local snapshot is present remotely, so no drift is reported.
|
|
assert.NotContains(t, out, "not found in backup destination store")
|
|
}
|
|
|
|
// TestListSnapshots_LocalOnlyReportedAsDrift covers a snapshot in the
|
|
// local index with no counterpart on the destination store, and checks
|
|
// the remediation hint names a command that actually exists.
|
|
func TestListSnapshots_LocalOnlyReportedAsDrift(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newListEnv(t)
|
|
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
|
|
|
err := env.v.ListSnapshots(false)
|
|
require.NoError(t, err)
|
|
|
|
out := env.stdout.String()
|
|
|
|
assert.Contains(t, out, listLocalID)
|
|
assert.Contains(t, out, "1 local snapshot record(s) not found in backup")
|
|
assert.Contains(t, out, "vaultik prune")
|
|
|
|
// There is no `vaultik snapshot cleanup` command; the hint must not
|
|
// name one.
|
|
assert.NotContains(t, out, "snapshot cleanup")
|
|
}
|
|
|
|
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
|
|
// doc comment: an unreachable destination is a warning plus local-only
|
|
// output, never a failure.
|
|
func TestListSnapshots_UnreachableRemoteDegrades(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newListEnv(t)
|
|
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
|
env.store.listErr = errRemoteUnreachable
|
|
|
|
// Zero exit code: the CLI turns a nil return into exit 0.
|
|
err := env.v.ListSnapshots(false)
|
|
require.NoError(t, err)
|
|
|
|
out := env.stdout.String()
|
|
|
|
assert.Contains(t, out, "Could not list backup destination store")
|
|
assert.Contains(t, out, "permission denied")
|
|
assert.Contains(t, out, "Showing snapshots from the local index only.")
|
|
|
|
// The local index is still shown.
|
|
assert.Contains(t, out, listLocalID)
|
|
|
|
// With no remote listing there is no basis for a drift claim, so
|
|
// none must be made.
|
|
assert.NotContains(t, out, "not found in backup destination store")
|
|
}
|
|
|
|
// TestListSnapshots_UnreadableManifestDoesNotHideOthers checks that one
|
|
// corrupt remote snapshot directory cannot suppress every other
|
|
// snapshot on the destination store.
|
|
func TestListSnapshots_UnreadableManifestDoesNotHideOthers(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newListEnv(t)
|
|
|
|
goodKey := env.addRemote(t, listRemoteID,
|
|
time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC))
|
|
|
|
badKey := snapshot.RemoteSnapshotKey("testhost_broken_2026-03-03T00:00:00Z")
|
|
err := env.store.Put(context.Background(),
|
|
"metadata/"+badKey+"/manifest.json.zst",
|
|
strings.NewReader("this is not a zstd stream"))
|
|
require.NoError(t, err)
|
|
|
|
err = env.v.ListSnapshots(false)
|
|
require.NoError(t, err)
|
|
|
|
out := env.stdout.String()
|
|
|
|
assert.Contains(t, out, "<remote only:"+goodKey[:12]+">")
|
|
assert.NotContains(t, out, "<remote only:"+badKey[:12]+">")
|
|
assert.Contains(t, out, "1 remote snapshot(s) could not be described")
|
|
}
|
|
|
|
// listJSONRow mirrors the JSON shape ListSnapshots emits, so the test
|
|
// asserts against the wire format rather than the Go struct.
|
|
//
|
|
//nolint:tagliatelle // snake_case is the established output format
|
|
type listJSONRow struct {
|
|
ID string `json:"id"`
|
|
RemoteKey string `json:"remote_key"`
|
|
Timestamp string `json:"timestamp"`
|
|
CompressedSize int64 `json:"compressed_size"`
|
|
LocallyTracked bool `json:"locally_tracked"`
|
|
RemotePresent *bool `json:"remote_present"`
|
|
}
|
|
|
|
// decodeListJSON parses the command's stdout, which must contain
|
|
// nothing but the JSON document.
|
|
func decodeListJSON(t *testing.T, out string) []listJSONRow {
|
|
t.Helper()
|
|
|
|
var rows []listJSONRow
|
|
|
|
err := json.Unmarshal([]byte(out), &rows)
|
|
require.NoError(t, err, "stdout must be parseable JSON: %q", out)
|
|
|
|
return rows
|
|
}
|
|
|
|
// TestListSnapshots_JSONMergedView covers the --json view of all three
|
|
// cases at once: tracked-and-present, tracked-but-missing remotely, and
|
|
// remote-only.
|
|
func TestListSnapshots_JSONMergedView(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
env := newListEnv(t)
|
|
|
|
syncedStart := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
|
|
env.addLocal(t, listLocalID, syncedStart)
|
|
env.addRemote(t, listLocalID, syncedStart)
|
|
|
|
driftedID := "testhost_home_2026-02-01T10:00:00Z"
|
|
env.addLocal(t, driftedID, time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC))
|
|
|
|
remoteKey := env.addRemote(t, listRemoteID,
|
|
time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC))
|
|
|
|
err := env.v.ListSnapshots(true)
|
|
require.NoError(t, err)
|
|
|
|
rows := decodeListJSON(t, env.stdout.String())
|
|
require.Len(t, rows, 3)
|
|
|
|
byKey := make(map[string]listJSONRow, len(rows))
|
|
for _, row := range rows {
|
|
byKey[row.RemoteKey] = row
|
|
}
|
|
|
|
synced := byKey[snapshot.RemoteSnapshotKey(listLocalID)]
|
|
assert.Equal(t, listLocalID, synced.ID)
|
|
assert.True(t, synced.LocallyTracked)
|
|
require.NotNil(t, synced.RemotePresent)
|
|
assert.True(t, *synced.RemotePresent)
|
|
|
|
drifted := byKey[snapshot.RemoteSnapshotKey(driftedID)]
|
|
assert.Equal(t, driftedID, drifted.ID)
|
|
assert.True(t, drifted.LocallyTracked)
|
|
require.NotNil(t, drifted.RemotePresent)
|
|
assert.False(t, *drifted.RemotePresent,
|
|
"a local-only snapshot must be visible as drift in --json too")
|
|
|
|
remoteOnly := byKey[remoteKey]
|
|
assert.False(t, remoteOnly.LocallyTracked)
|
|
assert.Empty(t, remoteOnly.ID,
|
|
"the human ID is unrecoverable and must not be fabricated")
|
|
assert.Len(t, remoteOnly.RemoteKey, 64,
|
|
"--json carries the full remote key, not the truncated form")
|
|
assert.Equal(t, int64(fiveMegabytes), remoteOnly.CompressedSize)
|
|
require.NotNil(t, remoteOnly.RemotePresent)
|
|
assert.True(t, *remoteOnly.RemotePresent)
|
|
}
|
|
|
|
// TestListSnapshots_JSONUnreachableRemote checks that a failed listing
|
|
// does not corrupt the JSON document with warning text, and that
|
|
// "unknown" is reported as null rather than as absence.
|
|
//
|
|
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
|
|
func TestListSnapshots_JSONUnreachableRemote(t *testing.T) {
|
|
env := newListEnv(t)
|
|
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
|
env.store.listErr = errRemoteUnreachable
|
|
|
|
stderr := captureProcessStderr(t, func() {
|
|
require.NoError(t, env.v.ListSnapshots(true))
|
|
})
|
|
|
|
// stdout must be nothing but the JSON document, so the warning has
|
|
// to go to stderr.
|
|
rows := decodeListJSON(t, env.stdout.String())
|
|
require.Len(t, rows, 1)
|
|
|
|
assert.Equal(t, listLocalID, rows[0].ID)
|
|
assert.True(t, rows[0].LocallyTracked)
|
|
assert.Nil(t, rows[0].RemotePresent,
|
|
"remote state is unknown when the destination cannot be listed")
|
|
|
|
assert.Contains(t, stderr, "Could not list backup destination store")
|
|
assert.Contains(t, stderr, "permission denied")
|
|
}
|
|
|
|
// useNonUTCLocalZone points time.Local at a fixed non-UTC zone for the
|
|
// duration of the test.
|
|
//
|
|
// Snapshot timestamps are stored as bare Unix seconds, so the zone a
|
|
// reader decodes them in is a decode choice rather than stored data —
|
|
// and on a UTC host a wrong choice is invisible. This makes it visible:
|
|
// with time.Local at +07:13, a row decoded in local time renders 7h13m
|
|
// away from the same instant decoded in UTC.
|
|
//
|
|
// time.Local is process-global, so a test using this must not call
|
|
// t.Parallel. Go runs every non-parallel test to completion before
|
|
// resuming any parallel one, so the mutation is not observable from
|
|
// another test.
|
|
//
|
|
//nolint:gosmopolitan // pinning time.Local is the entire point here
|
|
func useNonUTCLocalZone(t *testing.T) {
|
|
t.Helper()
|
|
|
|
const offsetSeconds = 7*60*60 + 13*60
|
|
|
|
previous := time.Local
|
|
time.Local = time.FixedZone("VaultikTest", offsetSeconds)
|
|
|
|
t.Cleanup(func() { time.Local = previous })
|
|
}
|
|
|
|
// TestListSnapshots_TimestampsAreUTCOnNonUTCHost is the regression guard
|
|
// for the merged TIMESTAMP column. Local rows come from the index
|
|
// database and remote-only rows come from a manifest; both render
|
|
// through the same zone-less format string, so both have to be in the
|
|
// same zone or the column silently shows two different wall clocks for
|
|
// the same instant.
|
|
//
|
|
// This test fails on any host if either source stops normalizing to UTC,
|
|
// because it pins time.Local to a zone that is not UTC.
|
|
//
|
|
//nolint:paralleltest // pins process-global time.Local; see useNonUTCLocalZone
|
|
func TestListSnapshots_TimestampsAreUTCOnNonUTCHost(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
useNonUTCLocalZone(t)
|
|
|
|
// One instant, rendered twice: once through a locally tracked
|
|
// snapshot and once through a snapshot only the destination store
|
|
// knows about.
|
|
instant := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
|
|
|
|
const wallClock = "2026-03-01 10:00:00"
|
|
|
|
env := newListEnv(t)
|
|
env.addLocal(t, listLocalID, instant)
|
|
env.addRemote(t, listLocalID, instant)
|
|
remoteKey := env.addRemote(t, listRemoteID, instant)
|
|
|
|
err := env.v.ListSnapshots(false)
|
|
require.NoError(t, err)
|
|
|
|
out := env.stdout.String()
|
|
|
|
assert.Contains(t, findTableRow(t, out, listLocalID), wallClock,
|
|
"a locally tracked row must render in UTC like every other row")
|
|
assert.Contains(t,
|
|
findTableRow(t, out, "<remote only:"+remoteKey[:12]+">"), wallClock)
|
|
|
|
// The --json timestamp carries its zone explicitly, so rows from the
|
|
// two sources must be string-comparable as well.
|
|
jsonEnv := newListEnv(t)
|
|
jsonEnv.addLocal(t, listLocalID, instant)
|
|
jsonEnv.addRemote(t, listLocalID, instant)
|
|
jsonEnv.addRemote(t, listRemoteID, instant)
|
|
|
|
err = jsonEnv.v.ListSnapshots(true)
|
|
require.NoError(t, err)
|
|
|
|
rows := decodeListJSON(t, jsonEnv.stdout.String())
|
|
require.Len(t, rows, 2)
|
|
|
|
for _, row := range rows {
|
|
assert.Equal(t, "2026-03-01T10:00:00Z", row.Timestamp,
|
|
"--json timestamps must be comparable between row types")
|
|
}
|
|
}
|
|
|
|
// TestListSnapshots_JSONReportsUnreadableManifests checks that a
|
|
// snapshot missing from the JSON document because its manifest could not
|
|
// be read is still announced. Table mode says so below the table; a
|
|
// machine consumer would otherwise see no difference between "that
|
|
// snapshot is not on the destination" and "that snapshot could not be
|
|
// read".
|
|
//
|
|
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
|
|
func TestListSnapshots_JSONReportsUnreadableManifests(t *testing.T) {
|
|
env := newListEnv(t)
|
|
|
|
goodKey := env.addRemote(t, listRemoteID,
|
|
time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC))
|
|
|
|
badKey := snapshot.RemoteSnapshotKey("testhost_broken_2026-03-03T00:00:00Z")
|
|
err := env.store.Put(context.Background(),
|
|
"metadata/"+badKey+"/manifest.json.zst",
|
|
strings.NewReader("this is not a zstd stream"))
|
|
require.NoError(t, err)
|
|
|
|
stderr := captureProcessStderr(t, func() {
|
|
require.NoError(t, env.v.ListSnapshots(true))
|
|
})
|
|
|
|
rows := decodeListJSON(t, env.stdout.String())
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, goodKey, rows[0].RemoteKey)
|
|
|
|
assert.Contains(t, stderr, "could not be described",
|
|
"a row dropped from the JSON document must be announced somewhere")
|
|
assert.Contains(t, stderr, `"unreadable":1`,
|
|
"the count of dropped rows must be reported, not just the fact")
|
|
}
|
|
|
|
// maxRemoteOnlyRowsForTest mirrors the maxRemoteOnlyRows cap in the
|
|
// package under test, which is unexported.
|
|
const maxRemoteOnlyRowsForTest = 1000
|
|
|
|
// TestListSnapshots_JSONReportsTruncation covers the row cap in --json
|
|
// mode. Past the cap the document is a partial listing, and silent
|
|
// truncation of a listing whose whole purpose is disaster recovery is
|
|
// the wrong failure mode: the consumer least able to notice is exactly
|
|
// the one reading JSON.
|
|
//
|
|
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
|
|
func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
|
|
env := newListEnv(t)
|
|
|
|
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
|
|
|
|
// One past the cap, so exactly one snapshot is omitted.
|
|
for i := range maxRemoteOnlyRowsForTest + 1 {
|
|
env.addRemote(t, fmt.Sprintf("otherhost_bulk_%04d", i), timestamp)
|
|
}
|
|
|
|
stderr := captureProcessStderr(t, func() {
|
|
require.NoError(t, env.v.ListSnapshots(true))
|
|
})
|
|
|
|
rows := decodeListJSON(t, env.stdout.String())
|
|
assert.Len(t, rows, maxRemoteOnlyRowsForTest)
|
|
|
|
assert.Contains(t, stderr, "Listing truncated")
|
|
assert.Contains(t, stderr, `"omitted":1`)
|
|
assert.Contains(t, stderr,
|
|
fmt.Sprintf(`"limit":%d`, maxRemoteOnlyRowsForTest))
|
|
}
|
|
|
|
// captureProcessStdout redirects the process's own stdout to a pipe,
|
|
// rebuilds the global logger, runs fn, and returns everything written to
|
|
// the pipe.
|
|
//
|
|
// The logger is rebuilt on purpose even though it is supposed to write
|
|
// to stderr: that is exactly what makes this a regression guard. If the
|
|
// logger ever goes back to os.Stdout, Initialize picks up the pipe and
|
|
// the log record shows up in the capture, breaking the JSON parse here
|
|
// the same way it would break `snapshot list --json | jq` in the field.
|
|
// Without the rebuild, a regressed logger would write to the real stdout
|
|
// the test process was started with and go unnoticed.
|
|
//
|
|
// Not parallel-safe: os.Stdout and the logger are process-global.
|
|
func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string {
|
|
t.Helper()
|
|
|
|
reader, writer, err := os.Pipe()
|
|
require.NoError(t, err)
|
|
|
|
previous := os.Stdout
|
|
os.Stdout = writer
|
|
|
|
log.Initialize(log.Config{})
|
|
|
|
drained := make(chan string, 1)
|
|
|
|
go func() {
|
|
var buf bytes.Buffer
|
|
|
|
_, _ = io.Copy(&buf, reader)
|
|
|
|
drained <- buf.String()
|
|
}()
|
|
|
|
fn(writer)
|
|
|
|
os.Stdout = previous
|
|
|
|
require.NoError(t, writer.Close())
|
|
|
|
captured := <-drained
|
|
|
|
require.NoError(t, reader.Close())
|
|
|
|
// Put the logger back on the restored streams.
|
|
log.Initialize(log.Config{})
|
|
|
|
return captured
|
|
}
|
|
|
|
// captureProcessStderr redirects the process's own stderr to a pipe,
|
|
// rebuilds the global logger over it, runs fn, and returns everything
|
|
// written.
|
|
//
|
|
// internal/log writes every diagnostic to os.Stderr and captures that
|
|
// file at Initialize time, so a warning logged during a listing lands on
|
|
// the process's real stderr, not on any writer a test can inject.
|
|
// Capturing the file descriptor is therefore the only way a test can see
|
|
// what the operator would see. The captured stream is a pipe rather than
|
|
// a terminal, so the records are JSON — the same form a redirected
|
|
// stderr gets in production.
|
|
//
|
|
// Not parallel-safe: os.Stderr and the logger are process-global.
|
|
func captureProcessStderr(t *testing.T, fn func()) string {
|
|
t.Helper()
|
|
|
|
reader, writer, err := os.Pipe()
|
|
require.NoError(t, err)
|
|
|
|
previous := os.Stderr
|
|
os.Stderr = writer
|
|
|
|
log.Initialize(log.Config{})
|
|
|
|
drained := make(chan string, 1)
|
|
|
|
go func() {
|
|
var buf bytes.Buffer
|
|
|
|
_, _ = io.Copy(&buf, reader)
|
|
|
|
drained <- buf.String()
|
|
}()
|
|
|
|
fn()
|
|
|
|
os.Stderr = previous
|
|
|
|
require.NoError(t, writer.Close())
|
|
|
|
captured := <-drained
|
|
|
|
require.NoError(t, reader.Close())
|
|
|
|
// Put the logger back on the restored streams.
|
|
log.Initialize(log.Config{})
|
|
|
|
return captured
|
|
}
|
|
|
|
// TestListSnapshots_JSONStdoutIsOnlyTheDocument is the regression guard
|
|
// for `snapshot list --json | jq` surviving a damaged destination store.
|
|
//
|
|
// Every stdout writer the command has — the JSON encoder and the UI —
|
|
// is pointed at one pipe here, exactly as they are pointed at one file
|
|
// descriptor in production, and the logger is rebuilt over that same
|
|
// pipe's process-level stdout so that a logger which regressed back to
|
|
// stdout would land in the capture. A single log line about a corrupt
|
|
// manifest ahead of the array is enough to break the parse, and that is
|
|
// what this asserts cannot happen.
|
|
//
|
|
// The two warnings are asserted on the separately captured stderr: they
|
|
// must be emitted, just not there.
|
|
//
|
|
//nolint:paralleltest // replaces os.Stdout, os.Stderr and the logger
|
|
func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) {
|
|
env := newListEnv(t)
|
|
|
|
goodKey := env.addRemote(t, listRemoteID,
|
|
time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC))
|
|
|
|
// A manifest that is not even a zstd stream.
|
|
badKey := snapshot.RemoteSnapshotKey("testhost_broken_2026-03-03T00:00:00Z")
|
|
err := env.store.Put(context.Background(),
|
|
"metadata/"+badKey+"/manifest.json.zst",
|
|
strings.NewReader("this is not a zstd stream"))
|
|
require.NoError(t, err)
|
|
|
|
// And a manifest that decodes but carries a timestamp no parser will
|
|
// accept: the second warning on this path.
|
|
oddKey := env.addRemoteRawTimestamp(t,
|
|
"testhost_odd_2026-03-04T00:00:00Z", "the day before yesterday")
|
|
|
|
var captured string
|
|
|
|
stderr := captureProcessStderr(t, func() {
|
|
captured = captureProcessStdout(t, func(stdout io.Writer) {
|
|
env.v.Stdout = stdout
|
|
env.v.UI = ui.NewWithColor(stdout, false)
|
|
|
|
require.NoError(t, env.v.ListSnapshots(true))
|
|
})
|
|
})
|
|
|
|
rows := decodeListJSON(t, captured)
|
|
require.Len(t, rows, 2, "the readable snapshots must both be listed")
|
|
|
|
byKey := make(map[string]listJSONRow, len(rows))
|
|
for _, row := range rows {
|
|
byKey[row.RemoteKey] = row
|
|
}
|
|
|
|
assert.Contains(t, byKey, goodKey)
|
|
assert.Contains(t, byKey, oddKey,
|
|
"an unparseable timestamp must not hide the snapshot itself")
|
|
assert.NotContains(t, byKey, badKey)
|
|
|
|
// Both warnings were emitted, on the stream that cannot corrupt the
|
|
// document.
|
|
assert.Contains(t, stderr, "Could not describe remote snapshot")
|
|
assert.Contains(t, stderr, "Remote manifest has an unparseable timestamp")
|
|
assert.Contains(t, stderr, "could not be described")
|
|
assert.Contains(t, stderr, `"unreadable":1`)
|
|
}
|