|
|
|
@@ -0,0 +1,239 @@
|
|
|
|
|
package web
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"io"
|
|
|
|
|
"log"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// R-204 item 3 — a restore must say WHAT IT RESTORED, and for the default mode, what it did not.
|
|
|
|
|
//
|
|
|
|
|
// THESE TESTS DRIVE THE REAL HANDLER, not the message helper (task §10: a test that reaches a helper
|
|
|
|
|
// while the mutation lives in the handler cannot observe it). offboxRestoreHandler runs, the restic
|
|
|
|
|
// exec is the only thing stubbed, and the assertion is on the flash the customer actually receives —
|
|
|
|
|
// backupMgr.RestoreStatus().Last.Message, the same field the wizard renders.
|
|
|
|
|
|
|
|
|
|
// scopeRunner is the restic exec seam. It answers the three calls a scratch restore makes
|
|
|
|
|
// (snapshots / unlock / restore) and RECORDS the restore argv, so Scenario F can assert the
|
|
|
|
|
// unit-vs-full distinction is still carried where it matters.
|
|
|
|
|
type scopeRunner struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
unitPath string
|
|
|
|
|
restoreArgs []string
|
|
|
|
|
sizeBytes int64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (sr *scopeRunner) run(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
|
|
|
|
sr.mu.Lock()
|
|
|
|
|
defer sr.mu.Unlock()
|
|
|
|
|
joined := strings.Join(args, " ")
|
|
|
|
|
switch {
|
|
|
|
|
case strings.Contains(joined, " snapshots ") || strings.HasSuffix(joined, " snapshots"):
|
|
|
|
|
out, _ := json.Marshal([]map[string]any{{
|
|
|
|
|
"short_id": "abc1234",
|
|
|
|
|
"id": "abc1234deadbeef",
|
|
|
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
|
|
|
// The snapshot holds BOTH the recovery unit and the customer's userdata — which is the
|
|
|
|
|
// whole point: a unit restore leaves the second one behind.
|
|
|
|
|
"paths": []string{sr.unitPath, filepath.Dir(filepath.Dir(sr.unitPath)) + "/userdata/immich"},
|
|
|
|
|
}})
|
|
|
|
|
return out, nil
|
|
|
|
|
case strings.Contains(joined, " stats "):
|
|
|
|
|
out, _ := json.Marshal(map[string]any{"total_size": sr.sizeBytes})
|
|
|
|
|
return out, nil
|
|
|
|
|
case strings.Contains(joined, " restore "):
|
|
|
|
|
sr.restoreArgs = append([]string{}, args...)
|
|
|
|
|
return []byte("restored"), nil
|
|
|
|
|
}
|
|
|
|
|
return []byte(""), nil // unlock and anything else: a clean no-op
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (sr *scopeRunner) lastRestoreArgs() []string {
|
|
|
|
|
sr.mu.Lock()
|
|
|
|
|
defer sr.mu.Unlock()
|
|
|
|
|
return append([]string{}, sr.restoreArgs...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// scopeServer wires a Server with a configured offbox manager whose restic exec is the stub above.
|
|
|
|
|
// The drive is a real temp dir registered as schedulable, so the scratch path resolves for real.
|
|
|
|
|
func scopeServer(t *testing.T) (*Server, *backup.Manager, *scopeRunner) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
lg := log.New(io.Discard, "", 0)
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
drive := filepath.Join(dir, "usb")
|
|
|
|
|
if err := os.MkdirAll(drive, 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
cfg := &config.Config{}
|
|
|
|
|
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
|
|
|
|
cfg.Paths.SystemDataPath = filepath.Join(dir, "sys")
|
|
|
|
|
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
|
|
|
|
cfg.Web.SessionSecret = "test-session-secret-abcdef"
|
|
|
|
|
|
|
|
|
|
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "USB", Schedulable: true}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
|
|
|
|
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
|
|
|
|
|
Schedule: "daily", EscrowState: "escrowed",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
m := backup.NewManager(cfg, sett, lg)
|
|
|
|
|
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if !m.OffboxConfigured() {
|
|
|
|
|
t.Fatal("offbox target not configured — the handler would refuse before reaching the outcome")
|
|
|
|
|
}
|
|
|
|
|
sr := &scopeRunner{
|
|
|
|
|
// The unit path as it appears INSIDE the snapshot: <nsRoot>/backups/primary/<app>.
|
|
|
|
|
unitPath: strings.TrimSuffix(m.OffsiteRestoreScratchPath("immich"), "/backups/offsite-restore/immich") + "/backups/primary/immich",
|
|
|
|
|
sizeBytes: 4 << 20, // 4 MiB — comfortably inside the headroom of a temp dir
|
|
|
|
|
}
|
|
|
|
|
m.SetOffboxRunner(sr.run)
|
|
|
|
|
|
|
|
|
|
s := &Server{cfg: cfg, settings: sett, backupMgr: m, logger: lg, version: "test"}
|
|
|
|
|
s.loadTemplates()
|
|
|
|
|
return s, m, sr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// postRestore drives the REAL handler and waits for the async restore to finish.
|
|
|
|
|
func postRestore(t *testing.T, s *Server, m *backup.Manager, form url.Values) backup.RestoreOpStatus {
|
|
|
|
|
t.Helper()
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backup/offbox/restore", strings.NewReader(form.Encode()))
|
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
s.offboxRestoreHandler(rr, req)
|
|
|
|
|
|
|
|
|
|
// Wait on a REAL completion marker (a finished Last with a FinishedAt), never a fixed sleep.
|
|
|
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
|
st := m.RestoreStatus()
|
|
|
|
|
if !st.Running && st.Last != nil && !st.Last.FinishedAt.IsZero() {
|
|
|
|
|
return st
|
|
|
|
|
}
|
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
t.Fatalf("restore did not finish within the deadline (status=%+v)", m.RestoreStatus())
|
|
|
|
|
return backup.RestoreOpStatus{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SCENARIO E — the DEFAULT (unit) restore's outcome names what it did NOT restore, and the next step.
|
|
|
|
|
//
|
|
|
|
|
// RED-PROOF: delete the „NEM kerültek vissza" sentence from restoreScratchOutcomeMsg (or revert the
|
|
|
|
|
// function to the single pre-R-204 sentence). The handler still succeeds and still flashes a
|
|
|
|
|
// „visszaállítva" message — and this test fails, which is exactly the silence being closed.
|
|
|
|
|
func TestOffboxRestore_UnitOutcomeNamesWhatItDidNotRestore(t *testing.T) {
|
|
|
|
|
s, m, _ := scopeServer(t)
|
|
|
|
|
|
|
|
|
|
st := postRestore(t, s, m, url.Values{"app": {"immich"}, "mode": {"unit"}})
|
|
|
|
|
if !st.Last.OK {
|
|
|
|
|
t.Fatalf("unit restore failed: %q", st.Last.Message)
|
|
|
|
|
}
|
|
|
|
|
msg := st.Last.Message
|
|
|
|
|
|
|
|
|
|
// It must name what CAME BACK…
|
|
|
|
|
for _, want := range []string{"immich", "be" + "állításai és adatbázisa visszaállítva"} {
|
|
|
|
|
if !strings.Contains(msg, want) {
|
|
|
|
|
t.Errorf("outcome does not state what was restored (missing %q): %q", want, msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// …and, the point of R-204 item 3, what did NOT.
|
|
|
|
|
if !strings.Contains(msg, "NEM kerültek vissza") {
|
|
|
|
|
t.Errorf("outcome does not state that the customer's own files were NOT restored: %q", msg)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(msg, "dokumentumok") {
|
|
|
|
|
t.Errorf("outcome does not name the files it left behind: %q", msg)
|
|
|
|
|
}
|
|
|
|
|
// …and the next step that actually gets them.
|
|
|
|
|
if !strings.Contains(msg, "Teljes vissza"+"állítás előkészítése") {
|
|
|
|
|
t.Errorf("outcome does not name the next step that returns the files: %q", msg)
|
|
|
|
|
}
|
|
|
|
|
// The scratch path is still named (the v0.147.0 4a guarantee must not regress).
|
|
|
|
|
if !strings.Contains(msg, m.OffsiteRestoreScratchPath("immich")) {
|
|
|
|
|
t.Errorf("outcome no longer names the folder it restored into: %q", msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The DEFAULT is `unit` (mode absent) and it must produce the SAME scoped outcome — the wizard always
|
|
|
|
|
// sets mode, but a mode-less POST must not fall into a message that overstates what it did.
|
|
|
|
|
func TestOffboxRestore_DefaultModeGetsTheScopedOutcome(t *testing.T) {
|
|
|
|
|
s, m, _ := scopeServer(t)
|
|
|
|
|
st := postRestore(t, s, m, url.Values{"app": {"immich"}}) // no mode at all
|
|
|
|
|
if !strings.Contains(st.Last.Message, "NEM kerültek vissza") {
|
|
|
|
|
t.Fatalf("the DEFAULT restore did not state its scope: %q", st.Last.Message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SCENARIO F — the full restore is unchanged: still two-step and size-gated, and its outcome does NOT
|
|
|
|
|
// carry the unit warning (a full restore did bring the files).
|
|
|
|
|
func TestOffboxRestore_FullPathUnchanged(t *testing.T) {
|
|
|
|
|
s, m, sr := scopeServer(t)
|
|
|
|
|
|
|
|
|
|
// Step 1: mode=full WITHOUT confirm must NOT restore — it computes and redirects with the reveal.
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/backup/offbox/restore",
|
|
|
|
|
strings.NewReader(url.Values{"app": {"immich"}, "mode": {"full"}}.Encode()))
|
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
s.offboxRestoreHandler(rr, req)
|
|
|
|
|
if rr.Code != http.StatusFound {
|
|
|
|
|
t.Fatalf("full step 1: want a redirect, got %d", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
loc := rr.Header().Get("Location")
|
|
|
|
|
if !strings.Contains(loc, "full_prep=immich") || !strings.Contains(loc, "full_size=") {
|
|
|
|
|
t.Fatalf("full step 1 did not reveal the size gate: Location=%q", loc)
|
|
|
|
|
}
|
|
|
|
|
if len(sr.lastRestoreArgs()) != 0 {
|
|
|
|
|
t.Fatal("full step 1 ran a restore before the customer confirmed — the size gate is bypassed")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 2: the revealed confirm executes, and the outcome says the files came with it.
|
|
|
|
|
st := postRestore(t, s, m, url.Values{"app": {"immich"}, "mode": {"full"}, "confirm": {"1"}})
|
|
|
|
|
if !st.Last.OK {
|
|
|
|
|
t.Fatalf("full restore failed: %q", st.Last.Message)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(st.Last.Message, "NEM kerültek vissza") {
|
|
|
|
|
t.Fatalf("the FULL restore wrongly claims the files were left behind: %q", st.Last.Message)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(st.Last.Message, "saját fájljaiddal együtt") {
|
|
|
|
|
t.Fatalf("the full outcome does not state that the files came with it: %q", st.Last.Message)
|
|
|
|
|
}
|
|
|
|
|
// And the mechanism that makes the two modes differ is still carried: unit passes --include, full
|
|
|
|
|
// does not. Asserted on the REAL argv the manager built.
|
|
|
|
|
if args := sr.lastRestoreArgs(); strings.Contains(strings.Join(args, " "), "--include") {
|
|
|
|
|
t.Fatalf("a FULL restore must not restrict to the unit: %v", args)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The unit restore's mechanism half: it DOES restrict to the unit path. Without this, Scenario E's
|
|
|
|
|
// message could be true today and quietly become a lie if --include were dropped.
|
|
|
|
|
func TestOffboxRestore_UnitRestrictsToTheUnitPath(t *testing.T) {
|
|
|
|
|
s, m, sr := scopeServer(t)
|
|
|
|
|
postRestore(t, s, m, url.Values{"app": {"immich"}, "mode": {"unit"}})
|
|
|
|
|
joined := strings.Join(sr.lastRestoreArgs(), " ")
|
|
|
|
|
if !strings.Contains(joined, "--include") {
|
|
|
|
|
t.Fatalf("a unit restore must restrict to the unit path, argv=%q", joined)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(joined, "/backups/primary/immich") {
|
|
|
|
|
t.Fatalf("a unit restore did not include the recovery unit path, argv=%q", joined)
|
|
|
|
|
}
|
|
|
|
|
}
|