Files
privatae-bastion/client-go/backupshared_bound_test.go
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

185 lines
5.6 KiB
Go

package main
// Gate: a folder shared with a host, on a network mount that stopped
// answering, holds up nothing. backup_list answers at once with the rest and
// names the folder; backup_push from it is refused with a sentence. The same
// bound as the host's own backup folder (opmask.go).
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// blockReadDir makes reading dir block until the test ends.
func blockReadDir(t *testing.T, dir string) {
t.Helper()
gate := make(chan struct{})
prev := backupReadDirFn
backupReadDirFn = func(p string) ([]os.DirEntry, error) {
if p == dir {
<-gate
}
return prev(p)
}
t.Cleanup(func() {
close(gate)
waitProbeIdle("list:" + dir)
backupReadDirFn = prev
})
}
// blockResolve makes resolving a file under dir (the push path) block until
// the test ends.
func blockResolve(t *testing.T, dir string) {
t.Helper()
gate := make(chan struct{})
prev := backupResolvePathFn
backupResolvePathFn = func(root, rpath string) (string, bool) {
if root == dir {
<-gate
}
return prev(root, rpath)
}
t.Cleanup(func() {
close(gate)
waitProbeIdle("push:" + dir)
backupResolvePathFn = prev
})
}
// waitProbeIdle waits for the probe of key to return, so the function it
// calls can be put back without a race.
func waitProbeIdle(key string) {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
opProbeMu.Lock()
busy := opProbeInFlight[key]
opProbeMu.Unlock()
if !busy {
return
}
time.Sleep(5 * time.Millisecond)
}
}
type listReply struct {
Backups []backupListEntry `json:"backups"`
Unavailable []backupUnavailable `json:"unavailable"`
}
func listReplyAs(t *testing.T, self, root string) (listReply, time.Duration) {
t.Helper()
type res struct {
out, errOut string
code int
}
done := make(chan res, 1)
start := time.Now()
go func() {
out, errOut, code := driveBackupOpAs(t, self, "backup_list", "", root, map[string]string{"op": "list"})
done <- res{out, errOut, code}
}()
select {
case r := <-done:
if r.code != 0 {
t.Fatalf("backup_list exited %d: %s", r.code, r.errOut)
}
var rep listReply
if err := json.Unmarshal([]byte(r.out), &rep); err != nil {
t.Fatalf("list reply: %v %s", err, r.out)
}
return rep, time.Since(start)
case <-time.After(5 * time.Second):
t.Fatal("backup_list hung on a shared folder that does not answer")
}
return listReply{}, 0
}
func TestASharedFolderThatDoesNotAnswerHoldsUpNothing(t *testing.T) {
root, _ := opMaskFixture(t) // pairing nas.host.privatae.ai, probe bound 100ms
const self = "nas.host.privatae.ai"
pushFixture(t, root, []byte("own backup"))
good, deadList, deadResolve, deadPush := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir()
for dir, name := range map[string]string{good: "good", deadList: "dead-list", deadResolve: "dead-resolve",
deadPush: "dead-push"} {
pushFixture(t, dir, []byte(name))
if _, err := addBackupShare(self, root, dir, name); err != nil {
t.Fatal(err)
}
}
// hangFolder first: its cleanup waits for every probe, so it runs last.
hangFolder(t, deadResolve) // the folder hangs as soon as it is resolved
blockReadDir(t, deadList) // the folder answers a resolve, then hangs when read
blockResolve(t, deadPush) // resolving a file in it hangs
for i := 0; i < 2; i++ { // the second time, the first probes are still stuck
rep, took := listReplyAs(t, self, root)
if took > 2*time.Second {
t.Fatalf("the listing waited %s", took)
}
folders := map[string]bool{}
for _, b := range rep.Backups {
folders[b.Folder] = true
}
if !folders["."] || !folders["shared/good"] {
t.Fatalf("the folders that answer were not listed: %+v", rep.Backups)
}
down := map[string]string{}
for _, u := range rep.Unavailable {
down[u.Folder] = u.Why
}
for _, f := range []string{"shared/dead-list", "shared/dead-resolve"} {
if folders[f] || !strings.Contains(down[f], "does not answer") {
t.Fatalf("%s: listed %v, unavailable %q", f, folders[f], down[f])
}
}
}
for _, c := range []struct{ name, why string }{
{"dead-resolve", "does not answer"}, {"dead-push", "does not answer"},
} {
start := time.Now()
_, why := backupResolveFile(self, root, "shared/"+c.name+"/"+fixtureData)
if !strings.Contains(why, c.why) || time.Since(start) > 2*time.Second {
t.Fatalf("push from %s: %q after %s", c.name, why, time.Since(start))
}
}
if abs, why := backupResolveFile(self, root, "shared/good/"+fixtureData); why != "" || abs != filepath.Join(good, fixtureData) {
t.Fatalf("a shared folder that answers stopped working: %q %q", abs, why)
}
}
// A shared folder swapped for a link to this bastion's own state after it was
// shared is not listed and not sent from.
func TestASharedFolderThatNowReachesTheBastionsStateIsLeftOut(t *testing.T) {
root, conf := opMaskFixture(t)
const self = "nas.host.privatae.ai"
base := t.TempDir()
shared := filepath.Join(base, "old")
_ = os.MkdirAll(shared, 0o700)
if _, err := addBackupShare(self, root, shared, "old"); err != nil {
t.Fatal(err)
}
_ = os.RemoveAll(shared)
if err := os.Symlink(conf, shared); err != nil {
t.Skip("no symlinks here")
}
_ = os.WriteFile(filepath.Join(conf, fixtureData), []byte("not a backup"), 0o600)
rep, _ := listReplyAs(t, self, root)
for _, b := range rep.Backups {
if b.Folder == "shared/old" {
t.Fatal("a shared folder that now is the configuration folder was listed")
}
}
if len(rep.Unavailable) != 1 || rep.Unavailable[0].Folder != "shared/old" {
t.Fatalf("unavailable: %+v", rep.Unavailable)
}
if _, why := backupResolveFile(self, root, "shared/old/"+fixtureData); why == "" {
t.Fatal("a file in the configuration folder could be sent")
}
}