Files
steve 13f58bd052 P3-X2: tree.list synchronous WS RPC + per-session cache
Foundational for the restore wizard's tree browser. The wizard needs to
lazy-load directory contents from a snapshot as the operator drills
down; this lands the transport.

- internal/api adds MsgTreeList (server → agent) + MsgTreeListResult
  (agent → server) with TreeListRequestPayload / TreeListEntry /
  TreeListResultPayload types. Reply correlates by Envelope.ID.
- internal/restic.ListTreeChildren wraps 'restic ls --json' and
  filters its recursive output to direct children of the requested
  path. Parser + path-normalisation + isDirectChild are unit-tested.
- internal/server/ws/rpc.go introduces a generic SendRPC helper on
  Hub: register a buffered channel keyed by ULID, send the request,
  block on ctx.Done()/timeout/reply. Reply routing piggybacks on the
  existing dispatchAgentMessage by adding a MsgTreeListResult case
  that forwards to the registered waiter; if no waiter is registered
  (caller already gave up) the stray reply is dropped quietly.
- cmd/agent gains a tree.list handler that runs ListTreeChildren on a
  fresh per-call context (60s ceiling) and ships the matching
  tree.list.result envelope. Errors surface in result.Error rather
  than as transport failures so the server-side waiter can render a
  sensible UI message.
- internal/server/http/tree_cache.go is the per-wizard-session cache
  layer (~30min TTL, sweep-on-access) that fetchTreeWithCache uses
  before falling through to SendRPC. Cached on success only; agent
  errors aren't cached so a transient failure doesn't poison the
  session.

Tests:
- internal/restic/ls_test.go covers parseLsChildren at root / mid-tree
  / leaf, plus normalizeTreePath and isDirectChild edge cases.
- internal/server/ws/rpc_test.go unit-tests the registry: round-trip,
  release semantics, concurrent waiters, ctx-cancel.
- internal/server/http/tree_rpc_test.go is the full round-trip: server
  SendRPC → fake-agent over a real WS → reply → server gets the
  payload. Plus a timeout test that confirms ~300ms timeouts terminate
  in ~300ms rather than waiting forever.

The cache is plumbed but no UI handler hits fetchTreeWithCache yet —
that lands with P3-01 (wizard backend). The unused-linter is suppressed
via nolint until the wizard wires it in.
2026-05-04 15:19:22 +01:00

124 lines
3.6 KiB
Go

package restic
import (
"strings"
"testing"
)
// realistic restic ls --json output sample. First line is the
// snapshot preamble, subsequent lines are nodes. Trimmed to a few
// entries that exercise depth filtering.
const sampleLsOutput = `{"struct_type":"snapshot","time":"2026-05-04T09:14:00Z","id":"f3a7b2c1"}
{"name":"etc","type":"dir","path":"/etc","permissions":"drwxr-xr-x","struct_type":"node"}
{"name":"nginx","type":"dir","path":"/etc/nginx","permissions":"drwxr-xr-x","struct_type":"node"}
{"name":"nginx.conf","type":"file","path":"/etc/nginx/nginx.conf","size":2400,"struct_type":"node"}
{"name":"sites-available","type":"dir","path":"/etc/nginx/sites-available","struct_type":"node"}
{"name":"alfa.conf","type":"file","path":"/etc/nginx/sites-available/alfa.conf","size":3100,"struct_type":"node"}
{"name":"default.conf","type":"file","path":"/etc/nginx/sites-available/default.conf","size":2900,"struct_type":"node"}
`
func TestParseLsChildrenAtRoot(t *testing.T) {
t.Parallel()
entries, err := parseLsChildren(strings.NewReader(sampleLsOutput), "/")
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(entries) != 1 {
t.Fatalf("entries: got %d (%+v), want 1", len(entries), entries)
}
if entries[0].Name != "etc" || entries[0].Path != "/etc" || entries[0].Type != "dir" {
t.Fatalf("entry: %+v", entries[0])
}
}
func TestParseLsChildrenAtEtc(t *testing.T) {
t.Parallel()
entries, err := parseLsChildren(strings.NewReader(sampleLsOutput), "/etc")
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(entries) != 1 {
t.Fatalf("entries: got %d, want 1 (just nginx, not nested children)", len(entries))
}
if entries[0].Name != "nginx" {
t.Fatalf("entry: %+v", entries[0])
}
}
func TestParseLsChildrenAtNginx(t *testing.T) {
t.Parallel()
entries, err := parseLsChildren(strings.NewReader(sampleLsOutput), "/etc/nginx")
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(entries) != 2 {
t.Fatalf("entries: got %d (%+v), want 2 (nginx.conf + sites-available, not nested)",
len(entries), entries)
}
gotNames := []string{entries[0].Name, entries[1].Name}
want := map[string]bool{"nginx.conf": true, "sites-available": true}
for _, n := range gotNames {
if !want[n] {
t.Errorf("unexpected name %q in result", n)
}
}
}
func TestParseLsChildrenAtSitesAvailable(t *testing.T) {
t.Parallel()
entries, err := parseLsChildren(strings.NewReader(sampleLsOutput), "/etc/nginx/sites-available")
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(entries) != 2 {
t.Fatalf("entries: got %d, want 2", len(entries))
}
for _, e := range entries {
if e.Type != "file" {
t.Errorf("expected file type, got %q on %q", e.Type, e.Name)
}
}
}
func TestNormalizeTreePath(t *testing.T) {
t.Parallel()
cases := []struct{ in, want string }{
{"", "/"},
{"/", "/"},
{"/etc", "/etc"},
{"/etc/", "/etc"},
{"etc/nginx", "/etc/nginx"},
{"/etc//nginx", "/etc/nginx"},
{"/etc/./nginx", "/etc/nginx"},
}
for _, c := range cases {
got := normalizeTreePath(c.in)
if got != c.want {
t.Errorf("normalizeTreePath(%q): got %q, want %q", c.in, got, c.want)
}
}
}
func TestIsDirectChild(t *testing.T) {
t.Parallel()
cases := []struct {
child, parent string
want bool
}{
{"/etc", "/", true},
{"/etc/nginx", "/", false},
{"/etc/nginx", "/etc", true},
{"/etc/nginx/conf", "/etc", false},
{"/etc/nginx/conf", "/etc/nginx", true},
{"/etc", "/etc", false},
{"/etcc", "/etc", false}, // prefix match guard
}
for _, c := range cases {
got := isDirectChild(c.child, c.parent)
if got != c.want {
t.Errorf("isDirectChild(%q, %q): got %v, want %v",
c.child, c.parent, got, c.want)
}
}
}