Files
restic-manager/internal/store/snapshots_test.go
T
steve 3904a78f14
CI / Test (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Build (windows/amd64) (push) Has been cancelled
CI / Build (linux/amd64) (push) Has been cancelled
CI / Build (linux/arm64) (push) Has been cancelled
P1-22: snapshot listing via restic snapshots --json
Agent calls restic snapshots --json after each successful backup
(60s timeout, separate from the backup ctx) and ships the projection
over the existing snapshots.report WS envelope. Failure here is
logged but doesn't fail the job — the next successful backup catches
the projection up.

Server-side ReplaceHostSnapshots is delete-then-insert plus a
hosts.snapshot_count update in one transaction so the dashboard's
per-host count stays consistent with the projection. New read
endpoint GET /api/hosts/{id}/snapshots returns the cached list with
a refreshed_at marker so the UI can show staleness when an agent
has been offline.

Schema: dropped the unused snapshots.repo_id FK (repos as a
first-class entity is P2 work), added short_id and refreshed_at
columns, switched the time index to DESC for the most-recent-first
list query. api.Snapshot gains short_id; size_bytes/file_count come
from the embedded summary block on restic 0.16+ and stay zero on
older clients.

Tests cover round-trip, authoritative replacement after forget+prune
shrinkage, and empty-after-wipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:20:57 +01:00

154 lines
4.4 KiB
Go

package store
import (
"context"
"testing"
"time"
)
// makeSnapHost inserts a minimal host row that snapshot tests can hang
// off. Returns the host id.
func makeSnapHost(t *testing.T, s *Store) string {
t.Helper()
const id = "01HSNAPHOST00000000000000"
if err := s.CreateHost(context.Background(), Host{
ID: id, Name: "snap-host", OS: "linux", Arch: "amd64",
AgentVersion: "dev", ResticVersion: "0.16.0", ProtocolVersion: 1,
EnrolledAt: time.Now().UTC(),
}, "tokenhash", ""); err != nil {
t.Fatalf("create host: %v", err)
}
return id
}
func TestReplaceHostSnapshotsRoundTrip(t *testing.T) {
t.Parallel()
s := openTestStore(t)
hostID := makeSnapHost(t, s)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
in := []Snapshot{
{
ID: "deadbeef" + "00000000000000000000000000000000000000000000000000000000",
ShortID: "deadbeef",
Time: now.Add(-2 * time.Hour),
Hostname: "snap-host",
Paths: []string{"/etc", "/home"},
Tags: []string{"daily"},
SizeBytes: 4096, FileCount: 12,
},
{
ID: "cafef00d" + "00000000000000000000000000000000000000000000000000000000",
ShortID: "cafef00d",
Time: now.Add(-1 * time.Hour),
Hostname: "snap-host",
Paths: []string{"/etc"},
SizeBytes: 8192, FileCount: 24,
},
}
if err := s.ReplaceHostSnapshots(ctx, hostID, in, now); err != nil {
t.Fatalf("replace: %v", err)
}
out, err := s.ListSnapshotsByHost(ctx, hostID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(out) != 2 {
t.Fatalf("want 2 snapshots, got %d", len(out))
}
// Ordered by time DESC — most recent first.
if out[0].ShortID != "cafef00d" {
t.Errorf("want most-recent first; got %q", out[0].ShortID)
}
if got := len(out[0].Paths); got != 1 {
t.Errorf("paths roundtrip lost: %v", out[0].Paths)
}
if out[1].Tags == nil || out[1].Tags[0] != "daily" {
t.Errorf("tags roundtrip lost: %v", out[1].Tags)
}
// Host snapshot_count is updated atomically.
h, err := s.GetHost(ctx, hostID)
if err != nil {
t.Fatalf("get host: %v", err)
}
if h.SnapshotCount != 2 {
t.Errorf("host snapshot_count = %d, want 2", h.SnapshotCount)
}
}
func TestReplaceHostSnapshotsIsAuthoritative(t *testing.T) {
t.Parallel()
s := openTestStore(t)
hostID := makeSnapHost(t, s)
ctx := context.Background()
mk := func(id, short string, tOff time.Duration) Snapshot {
return Snapshot{
ID: id, ShortID: short, Time: time.Now().UTC().Add(tOff),
Hostname: "snap-host", Paths: []string{"/x"},
}
}
first := []Snapshot{
mk("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "aaaaaaaa", -3*time.Hour),
mk("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "bbbbbbbb", -2*time.Hour),
mk("cccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccc", -1*time.Hour),
}
if err := s.ReplaceHostSnapshots(ctx, hostID, first, time.Now().UTC()); err != nil {
t.Fatalf("replace 1: %v", err)
}
// Subsequent forget+prune on the host: only one snapshot remains.
second := []Snapshot{
mk("cccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccc", -1*time.Hour),
}
if err := s.ReplaceHostSnapshots(ctx, hostID, second, time.Now().UTC()); err != nil {
t.Fatalf("replace 2: %v", err)
}
out, err := s.ListSnapshotsByHost(ctx, hostID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(out) != 1 || out[0].ShortID != "cccccccc" {
t.Errorf("after second replace, want [cccccccc], got %+v", out)
}
h, _ := s.GetHost(ctx, hostID)
if h.SnapshotCount != 1 {
t.Errorf("snapshot_count should track replacement: %d", h.SnapshotCount)
}
}
func TestReplaceHostSnapshotsEmpty(t *testing.T) {
t.Parallel()
s := openTestStore(t)
hostID := makeSnapHost(t, s)
ctx := context.Background()
// First a non-empty replace.
if err := s.ReplaceHostSnapshots(ctx, hostID, []Snapshot{
{ID: "1111111111111111111111111111111111111111111111111111111111111111",
ShortID: "11111111", Time: time.Now().UTC(), Hostname: "snap-host",
Paths: []string{"/x"}},
}, time.Now().UTC()); err != nil {
t.Fatalf("replace 1: %v", err)
}
// Then empty — host has been wiped.
if err := s.ReplaceHostSnapshots(ctx, hostID, nil, time.Now().UTC()); err != nil {
t.Fatalf("replace empty: %v", err)
}
out, err := s.ListSnapshotsByHost(ctx, hostID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(out) != 0 {
t.Errorf("want empty, got %d", len(out))
}
h, _ := s.GetHost(ctx, hostID)
if h.SnapshotCount != 0 {
t.Errorf("snapshot_count should reset to 0, got %d", h.SnapshotCount)
}
}