6d295bc9f6
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.
124 lines
3.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|