All checks were successful
check / check (push) Successful in 6s
ListSnapshots built its table entirely from the local SQLite index. The only remote access, reportRemoteDrift, was gated on AgeSecretKey != "", so on a correctly configured host - which by design holds no private key - snapshot list never contacted the destination store at all. A user who lost their local index could not see their own backups, and the "<remote only>" cell the README documents was unreachable dead code. The listing is now the union of the local index and the destination store, with no age_secret_key gate. Remote-only snapshots cannot have their hostname or name recovered - RemoteSnapshotKey is one-way and the manifest stores the hash - so they are listed by abbreviated remote key with the real timestamp and compressed size from the manifest, and "<remote only>" in the two columns that require the local index. Nothing new is written to remote storage and the human ID is never fabricated. An unreachable destination degrades to local-only with a warning and a zero exit code. remote_present is null rather than false in that case, so "absent" and "unknown" stay distinguishable and no drift is claimed from a listing that never happened. Also: - Snapshot timestamps are normalised to UTC in scanSnapshotRows, the single point where they enter the domain. Previously one of three scanners omitted .UTC(), so on a non-UTC host the same snapshot rendered a different time depending on whether it was locally tracked. - The 1000-row cap and the unreadable-manifest count are reported in --json mode as well as table mode, so machine consumers cannot be silently truncated. The JSON shape is unchanged. - Warnings raised while listing are routed to stderr rather than the logger, which writes to stdout and would corrupt the JSON document. This is a local workaround for the logger bug tracked in #82 and should be removed when that lands. - downloadManifestByKey is now the only remote manifest reader, so the manifest privacy question in #81 has a single call site to change. - The orphaned "vaultik snapshot cleanup" hint now names vaultik prune; that command was folded into prune by the 2026-07-02 consolidation.
802 lines
25 KiB
Go
802 lines
25 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
|
|
stderr *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{}
|
|
stderr := &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: stderr,
|
|
Stdin: &bytes.Buffer{},
|
|
UI: ui.NewWithColor(stdout, false),
|
|
}
|
|
v.SetContext(ctx)
|
|
|
|
return &listEnv{v: v, store: store, stdout: stdout, stderr: stderr}
|
|
}
|
|
|
|
// 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.
|
|
func TestListSnapshots_JSONUnreachableRemote(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
|
|
|
|
err := env.v.ListSnapshots(true)
|
|
require.NoError(t, err)
|
|
|
|
// 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, env.stderr.String(),
|
|
"could not list backup destination store")
|
|
assert.Contains(t, env.stderr.String(), "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".
|
|
func TestListSnapshots_JSONReportsUnreadableManifests(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(true)
|
|
require.NoError(t, err)
|
|
|
|
rows := decodeListJSON(t, env.stdout.String())
|
|
require.Len(t, rows, 1)
|
|
assert.Equal(t, goodKey, rows[0].RemoteKey)
|
|
|
|
assert.Contains(t, env.stderr.String(),
|
|
"1 remote snapshot(s) could not be described",
|
|
"a row dropped from the JSON document must be announced somewhere")
|
|
}
|
|
|
|
// 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.
|
|
func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
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)
|
|
}
|
|
|
|
err := env.v.ListSnapshots(true)
|
|
require.NoError(t, err)
|
|
|
|
rows := decodeListJSON(t, env.stdout.String())
|
|
assert.Len(t, rows, maxRemoteOnlyRowsForTest)
|
|
|
|
assert.Contains(t, env.stderr.String(), "listing truncated")
|
|
assert.Contains(t, env.stderr.String(), "1 further remote-only")
|
|
}
|
|
|
|
// captureProcessStdout redirects the process's own stdout to a pipe,
|
|
// rebuilds the global logger over it, runs fn, and returns everything
|
|
// written.
|
|
//
|
|
// internal/log builds its logger over os.Stdout at construction time and
|
|
// offers no injectable sink (issue #82), so a warning logged during a
|
|
// --json listing lands on the process's real stdout, not on any writer a
|
|
// test can inject. Capturing the file descriptor is therefore the only
|
|
// way a test can see what `snapshot list --json | jq` would see.
|
|
//
|
|
// 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
|
|
|
|
// Rebuild the logger so it writes to the pipe rather than to the
|
|
// real stdout the test process was started with.
|
|
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 stdout.
|
|
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, the UI, and
|
|
// the global logger — is pointed at one pipe here, exactly as they are
|
|
// pointed at one file descriptor in production. 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.
|
|
//
|
|
//nolint:paralleltest // replaces os.Stdout and the global 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")
|
|
|
|
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.
|
|
stderr := env.stderr.String()
|
|
assert.Contains(t, stderr, "Could not describe remote snapshot")
|
|
assert.Contains(t, stderr, "Remote manifest has an unparseable timestamp")
|
|
assert.Contains(t, stderr, "1 remote snapshot(s) could not be described")
|
|
}
|