4c8f0d2919
gates / gates (push) Successful in 12s
An app whose Tier-2 copy holds no file legs but a full recovery-unit mirror - 45 of the 53 catalog templates - was told to press a button on a DIFFERENT page. Since R-102 the data it is asking for is restorable from the copy it is looking at. New POST /backup/tier2/unit-restore and backupTier2UnitRestoreHandler: same guards, same restoreOpBlocked() refusal (R-351b), same async shape as the file restore beside it, plus a fail-closed pre-flight so the app is never stopped for a mirror that could not be opened. The outcome reuses unitRestoreOutcomeMsg and adds which copy overwrote the live data. The row offers the action where the refusal was, in a danger style, as a SEPARATE button. The two are not merged: one adds what is missing, the other overwrites. The confirm carries that difference in words and names the copy's date - and says so differently when that date is only an ATTEMPT (R-101). It is built from named Go constants rather than assembled inside an HTML attribute, so a test can assert it verbatim; fmtTimeStr now delegates to a package-level fmtRFC3339Local so the confirm and the outcome cannot render the same date two ways. tier2NoCoverageMsg is NARROWED to the case that remains - no legs and no openable unit - and still names the route that works. tier2UnitNotCoveredMsg is NOT deleted: it is appended where the FILE restore ran and is still exactly true of it. Tests C1-C2 and D1-D6 plus four more. Red-proofs: C1 (widen CanRestore to include HasUnit -> the unit-only cases fail), D6 (drop EndRestoreOp from the handler goroutine -> 'the restore never published a result').
414 lines
18 KiB
Go
414 lines
18 KiB
Go
package web
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"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-103 Group D — the surface: the refusal becomes an action.
|
|
//
|
|
// Before v0.229.0 an app whose Tier-2 copy held only a recovery unit got a message telling it to
|
|
// press a button on a DIFFERENT page. The data it was asking for is in the copy it is looking at, and
|
|
// since R-102 it is restorable from there. The action now lives where the refusal was.
|
|
//
|
|
// Every string assertion here compares against the NAMED CONSTANT rather than a Hungarian literal
|
|
// retyped in the test. That is R-364 discipline in its strongest form: a grep for accented text
|
|
// returned zero for strings that were present, and a re-typed literal can differ from the shipped one
|
|
// by a character nobody sees.
|
|
|
|
// --- the surface (template) -----------------------------------------------------------------
|
|
|
|
// r103Row is a Tier-2-configured row with a successful copy — the shape the template needs before it
|
|
// will render the actions block at all.
|
|
func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow {
|
|
row := AppBackupRow{
|
|
StackName: "docmost", DisplayName: "Docmost",
|
|
Tier2Configured: true, Tier2Dest: "flash", Tier2Schedule: "Naponta",
|
|
Tier2LastRun: date, Tier2LastStatus: "ok", Tier2StatusBadge: "Sikeres",
|
|
Tier2LastSuccess: date, Tier2SuccessTracked: true,
|
|
Tier2UnitRestorable: unitRestorable,
|
|
}
|
|
if unitRestorable {
|
|
row.Tier2CopyDate, row.Tier2CopyDateProven = date, proven
|
|
row.Tier2UnitConfirm = tier2UnitConfirmMsg(date, proven)
|
|
}
|
|
return row
|
|
}
|
|
|
|
// D1 — TestR103_UnitActionOfferedWhenTheMirrorExists.
|
|
func TestR103_UnitActionOfferedWhenTheMirrorExists(t *testing.T) {
|
|
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{
|
|
r103Row(true, "2026-08-25T03:30:00Z", true),
|
|
}))
|
|
if !strings.Contains(html, `action="/backup/tier2/unit-restore"`) {
|
|
t.Error("the unit-restore form is not on the page — the refusal is still a dead end")
|
|
}
|
|
if !strings.Contains(html, tier2UnitActionLabel) {
|
|
t.Errorf("the action is not labelled %q", tier2UnitActionLabel)
|
|
}
|
|
// The additive action must still be there, separately. Merging them is forbidden: they are
|
|
// different promises (one adds, one overwrites).
|
|
if !strings.Contains(html, `action="/backup/tier2/restore"`) {
|
|
t.Error("the additive file restore disappeared from the row")
|
|
}
|
|
// The destructive one is visually distinct from the additive one beside it.
|
|
if !strings.Contains(html, "btn-danger-outline") {
|
|
t.Error("the destructive action does not carry a danger style")
|
|
}
|
|
}
|
|
|
|
// D2 — TestR103_UnitActionAbsentWhenItDoesNot. Scenario G: no legs and no openable unit → an honest
|
|
// refusal, and NO unit action. A button offered over a copy the restore would refuse is worse than no
|
|
// button, because it is a promise withdrawn at the moment of use.
|
|
func TestR103_UnitActionAbsentWhenItDoesNot(t *testing.T) {
|
|
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{
|
|
r103Row(false, "2026-08-25T03:30:00Z", true),
|
|
}))
|
|
if strings.Contains(html, "/backup/tier2/unit-restore") {
|
|
t.Error("the unit action was offered for a copy with no openable unit")
|
|
}
|
|
if strings.Contains(html, tier2UnitActionLabel) {
|
|
t.Error("the unit action's label leaked onto a row that must not offer it")
|
|
}
|
|
// NEGATIVE CONTROL for D1's assertions: the row itself did render, so D1's positives were not
|
|
// an artefact of the whole block being absent.
|
|
if !strings.Contains(html, `action="/backup/tier2/restore"`) {
|
|
t.Fatal("the row did not render at all — D1's positive assertions prove nothing")
|
|
}
|
|
}
|
|
|
|
// D3 — TestR103_ConfirmStatesTheOverwrite. The confirm must carry the DIFFERENCE the register
|
|
// requires: a destructive operation reached from a non-destructive surface says so, and says how it
|
|
// differs from the action beside it.
|
|
func TestR103_ConfirmStatesTheOverwrite(t *testing.T) {
|
|
confirm := tier2UnitConfirmMsg("2026-08-25T03:30:00Z", true)
|
|
|
|
if !strings.Contains(confirm, tier2UnitConfirmBase) {
|
|
t.Error("the confirm does not state that the operation OVERWRITES live data")
|
|
}
|
|
if !strings.Contains(confirm, tier2UnitConfirmContrast) {
|
|
t.Error("the confirm does not state how it differs from the additive restore beside it")
|
|
}
|
|
// NEGATIVE CONTROL: the additive restore's own confirm must NOT have acquired this language.
|
|
// Without it, a test that passed because both buttons warn about overwriting would look green.
|
|
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{
|
|
r103Row(true, "2026-08-25T03:30:00Z", true),
|
|
}))
|
|
fileConfirmStart := strings.Index(html, "Visszaállítja a hiányzó fájlokat")
|
|
if fileConfirmStart < 0 {
|
|
t.Fatal("the additive confirm is gone — the negative control has nothing to check")
|
|
}
|
|
fileConfirm := html[fileConfirmStart:]
|
|
if end := strings.Index(fileConfirm, `">`); end > 0 {
|
|
fileConfirm = fileConfirm[:end]
|
|
}
|
|
if strings.Contains(fileConfirm, "FEL") {
|
|
t.Errorf("the ADDITIVE confirm gained overwrite language: %q", fileConfirm)
|
|
}
|
|
// And the confirm reaches the markup as the rendered attribute, not only as a Go constant.
|
|
if !strings.Contains(html, `data-confirm=`) {
|
|
t.Error("no data-confirm attribute rendered")
|
|
}
|
|
if !strings.Contains(html, "FEL") {
|
|
t.Error("the destructive wording never reached the page")
|
|
}
|
|
}
|
|
|
|
// D4 — TestR103_ConfirmNamesTheCopyDate. Scenario E. The action overwrites live data with a copy of a
|
|
// certain age, and „nobody restores last week over today by accident" is only true if the date is on
|
|
// the screen. R-101 governs the wording when the date is an ATTEMPT rather than a proven copy.
|
|
func TestR103_ConfirmNamesTheCopyDate(t *testing.T) {
|
|
const stamp = "2026-08-25T03:30:00Z"
|
|
rendered := fmtRFC3339Local(stamp)
|
|
if rendered == stamp {
|
|
t.Fatal("the stamp did not render as a local date — the assertion below would be vacuous")
|
|
}
|
|
|
|
proven := tier2UnitConfirmMsg(stamp, true)
|
|
if !strings.Contains(proven, rendered) {
|
|
t.Errorf("the confirm does not name the copy's date: %q", proven)
|
|
}
|
|
unproven := tier2UnitConfirmMsg(stamp, false)
|
|
if !strings.Contains(unproven, rendered) {
|
|
t.Errorf("the unproven confirm does not name the date: %q", unproven)
|
|
}
|
|
if proven == unproven {
|
|
t.Error("a proven copy and a bare attempt got the SAME sentence — R-101 exactly")
|
|
}
|
|
// A copy with no recorded date still gets the warning; it just cannot name one.
|
|
none := tier2UnitConfirmMsg("", false)
|
|
if !strings.Contains(none, tier2UnitConfirmBase) || !strings.Contains(none, tier2UnitConfirmContrast) {
|
|
t.Errorf("a dateless copy lost its confirm entirely: %q", none)
|
|
}
|
|
|
|
// And it is on the page, not merely constructible.
|
|
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{r103Row(true, stamp, true)}))
|
|
if !strings.Contains(html, rendered) {
|
|
t.Errorf("the copy's date %q is not in the rendered row", rendered)
|
|
}
|
|
}
|
|
|
|
// TestR103_AvailableMsgNamesTheButtonByItsLabel — the refusal that now points AT the new action must
|
|
// name it by the label the button actually carries, or it points at nothing.
|
|
func TestR103_AvailableMsgNamesTheButtonByItsLabel(t *testing.T) {
|
|
if !strings.Contains(tier2UnitAvailableMsg, tier2UnitActionLabel) {
|
|
t.Errorf("tier2UnitAvailableMsg does not name %q", tier2UnitActionLabel)
|
|
}
|
|
// It must NOT send anyone to another page any more; that is the R-103 defect it replaces.
|
|
if strings.Contains(tier2UnitAvailableMsg, "oldalon") {
|
|
t.Error("the message still routes the customer to another page for a copy restorable here")
|
|
}
|
|
// The surviving no-coverage message still names the route that works.
|
|
if !strings.Contains(tier2NoCoverageMsg, "oldalon") {
|
|
t.Error("tier2NoCoverageMsg no longer names any route — Scenario G requires one")
|
|
}
|
|
// C3 — tier2UnitNotCoveredMsg is NOT deleted: it is still appended where the FILE restore ran.
|
|
if tier2UnitNotCoveredMsg == "" {
|
|
t.Fatal("tier2UnitNotCoveredMsg was deleted")
|
|
}
|
|
if !strings.Contains(readSourceFile(t, "handlers.go"), `msg += " " + tier2UnitNotCoveredMsg`) {
|
|
t.Error("tier2UnitNotCoveredMsg is no longer appended by the file-restore handler")
|
|
}
|
|
}
|
|
|
|
func readSourceFile(t *testing.T, name string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// --- the handler ----------------------------------------------------------------------------
|
|
|
|
// r103Provider is a StackDataProvider that completes every lifecycle call, so the restore goroutine
|
|
// runs to its outcome instead of parking.
|
|
type r103Provider struct{ hdd string }
|
|
|
|
func (p *r103Provider) GetStackComposePath(string) (string, bool) { return "", false }
|
|
func (p *r103Provider) ListDeployedStacks() []backup.StackSummary { return nil }
|
|
func (p *r103Provider) GetStackHDDMounts(string) []string { return nil }
|
|
func (p *r103Provider) GetStackHDDPath(string) string { return p.hdd }
|
|
func (p *r103Provider) GetImportRoot() string { return "" }
|
|
func (p *r103Provider) GetDockerVolumes(string) []string { return nil }
|
|
func (p *r103Provider) StopStack(string) error { return nil }
|
|
func (p *r103Provider) StartStack(string) error { return nil }
|
|
func (p *r103Provider) RefreshAndIsRunning(string) bool { return true }
|
|
func (p *r103Provider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
|
|
return backup.RecoveryInfo{}, false
|
|
}
|
|
func (p *r103Provider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
|
|
return nil, false
|
|
}
|
|
func (p *r103Provider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
|
func (p *r103Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
|
|
return nil
|
|
}
|
|
func (p *r103Provider) StartStackServices(string, []string) error { return nil }
|
|
|
|
const r103CopyStamp = "2026-08-25T03:30:00Z"
|
|
|
|
// newR103Server wires a Server over a real backup.Manager whose recorded Tier-2 copy for "app" holds
|
|
// an OPENABLE recovery unit mirror. Nothing is stubbed between the handler and the manager: the whole
|
|
// point of D6 is that the wiring is what is under test.
|
|
func newR103Server(t *testing.T, withUnit bool) (*Server, *backup.Manager) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
live := filepath.Join(tmp, "usb")
|
|
dest := filepath.Join(tmp, "flash")
|
|
lg := log.New(io.Discard, "", 0)
|
|
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []string{live, dest} {
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: filepath.Base(p)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{
|
|
Enabled: true, Method: "rsync", DestinationPath: dest,
|
|
LastRun: r103CopyStamp, LastSuccess: r103CopyStamp, SuccessTracked: true, LastStatus: "ok",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
destBase := filepath.Join(dest, "backups", "secondary", "app")
|
|
write := func(rel, body string) {
|
|
p := filepath.Join(destBase, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write(".felhom-tier2-layout", "2")
|
|
if withUnit {
|
|
write("recovery-unit/manifest.json", `{"schema_version":1,"app_name":"app"}`)
|
|
write("recovery-unit/compose/app.yaml", "deployed: true\nenv:\n SUBDOMAIN: app\n")
|
|
write("recovery-unit/compose/docker-compose.yml", "services:\n app:\n image: example/app:1\n")
|
|
} else {
|
|
// A directory that exists and is not a package — the fail-closed case.
|
|
if err := os.MkdirAll(filepath.Join(destBase, "recovery-unit"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.DataDir = tmp
|
|
m := backup.NewManager(cfg, sett, lg)
|
|
m.SetStackProvider(&r103Provider{hdd: live})
|
|
return &Server{cfg: cfg, backupMgr: m, logger: lg}, m
|
|
}
|
|
|
|
func postTier2UnitRestore(t *testing.T, s *Server, stack string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
form := url.Values{"stack_name": {stack}}
|
|
req := httptest.NewRequest(http.MethodPost, "/backup/tier2/unit-restore", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
s.backupTier2UnitRestoreHandler(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func waitRestoreDone(t *testing.T, m *backup.Manager) backup.RestoreOpStatus {
|
|
t.Helper()
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
st := m.RestoreStatus()
|
|
if !st.Running && st.Last != nil {
|
|
return st
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
t.Fatal("the restore never published a result")
|
|
return backup.RestoreOpStatus{}
|
|
}
|
|
|
|
// D5 — TestR103_SecondPressIsRefused. Scenario H, R-351b. The concurrency flag is only taken inside
|
|
// the goroutine, AFTER the handler returns, so a guard reading IsRunning() alone leaves a window in
|
|
// which a second press starts a second run and is told „elindult".
|
|
func TestR103_SecondPressIsRefused(t *testing.T) {
|
|
s, m := newR103Server(t, true)
|
|
m.BeginRestoreOp("restore", "other-app") // a restore is already in flight, display-flag set
|
|
|
|
rec := postTier2UnitRestore(t, s, "app")
|
|
loc := rec.Header().Get("Location")
|
|
if !strings.Contains(loc, "flash_error") {
|
|
t.Fatalf("a second press was accepted: %q", loc)
|
|
}
|
|
// The identity of the FIRST operation survives — the consequence, not which flag was read.
|
|
if st := m.RestoreStatus(); st.Stack != "other-app" {
|
|
t.Errorf("the in-flight op was replaced by the refused one: %+v", st)
|
|
}
|
|
}
|
|
|
|
// D6 — TestR103_HandlerPublishesTheOutcome. THE SEAM TEST.
|
|
//
|
|
// It reads the outcome off RestoreStatus().Last.Message — the only place a customer sees it — rather
|
|
// than calling the manager and inspecting a return value. Three shipped defects in this project came
|
|
// from testing a component whose caller never invoked it (R-106's fakeObserver being the clearest),
|
|
// and the mechanism here is exactly that shape: a correct RestoreTier2Unit wired to nothing would
|
|
// leave the banner silent and every manager-level test green.
|
|
//
|
|
// Red-proof (recorded in REPORT.md): drop the `s.backupMgr.EndRestoreOp(true, msg)` call at the end
|
|
// of the handler's goroutine → this test fails on "the restore never published a result".
|
|
func TestR103_HandlerPublishesTheOutcome(t *testing.T) {
|
|
s, m := newR103Server(t, true)
|
|
|
|
rec := postTier2UnitRestore(t, s, "app")
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want 302", rec.Code)
|
|
}
|
|
if loc := rec.Header().Get("Location"); strings.Contains(loc, "flash_error") {
|
|
t.Fatalf("the restore was refused: %q", loc)
|
|
}
|
|
|
|
st := waitRestoreDone(t, m)
|
|
if !st.Last.OK {
|
|
t.Fatalf("outcome reported failure: %q", st.Last.Message)
|
|
}
|
|
if st.Last.Stack != "app" || st.Last.Op != "tier2-unit-restore" {
|
|
t.Errorf("the published result names the wrong operation: op=%q stack=%q", st.Last.Op, st.Last.Stack)
|
|
}
|
|
// The sentence is yesterday's honest outcome PLUS the clause naming which copy overwrote the live
|
|
// data (Scenario E). Asserted as an exact composition so neither half can silently vanish.
|
|
wantOutcome := unitRestoreOutcomeMsg("app", backup.UnitRestoreResult{})
|
|
wantSource := tier2UnitSourceMsg(backup.Tier2Coverage{CopyLastSuccess: r103CopyStamp})
|
|
if wantSource == "" {
|
|
t.Fatal("the fixture recorded no copy date — the assertion below would be vacuous")
|
|
}
|
|
if want := wantOutcome + " " + wantSource; st.Last.Message != want {
|
|
t.Errorf("published message =\n %q\nwant\n %q", st.Last.Message, want)
|
|
}
|
|
if !strings.Contains(st.Last.Message, fmtRFC3339Local(r103CopyStamp)) {
|
|
t.Error("the outcome does not name the date of the copy it restored from")
|
|
}
|
|
}
|
|
|
|
// TestR103_UnitRestoreRefusesAnUnopenableMirrorWithoutStopping — the handler's fail-closed pre-flight.
|
|
// A directory is not a package, and refusing before BeginRestoreOp means no banner, no outage and no
|
|
// rewritten definition.
|
|
func TestR103_UnitRestoreRefusesAnUnopenableMirrorWithoutStopping(t *testing.T) {
|
|
s, m := newR103Server(t, false)
|
|
|
|
rec := postTier2UnitRestore(t, s, "app")
|
|
loc := rec.Header().Get("Location")
|
|
if !strings.Contains(loc, "flash_error") {
|
|
t.Fatalf("the restore was accepted over an unopenable mirror: %q", loc)
|
|
}
|
|
if !strings.Contains(loc, url.QueryEscape(tier2NoCoverageMsg)) {
|
|
t.Errorf("refusal flash = %q, want tier2NoCoverageMsg", loc)
|
|
}
|
|
if st := m.RestoreStatus(); st.Running || st.Last != nil {
|
|
t.Errorf("an operation was begun despite the refusal: %+v", st)
|
|
}
|
|
}
|
|
|
|
// TestR103_UnitRestoreHandlerGuards — the same three guards the two restores beside it carry, proven
|
|
// to run BEFORE any work (backupMgr is nil, so reaching it would panic).
|
|
func TestR103_UnitRestoreHandlerGuards(t *testing.T) {
|
|
s := &Server{logger: log.New(io.Discard, "", 0)} // backupMgr nil on purpose
|
|
for name, want := range map[string]string{
|
|
"../../etc": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v",
|
|
"a/b": "%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v",
|
|
"": "Hi%C3%A1nyz%C3%B3+param%C3%A9terek",
|
|
} {
|
|
rec := postTier2UnitRestore(t, s, name)
|
|
if loc := rec.Header().Get("Location"); !strings.Contains(loc, want) {
|
|
t.Errorf("%q: redirect = %q, want flash %q", name, loc, want)
|
|
}
|
|
}
|
|
rec := postTier2UnitRestore(t, s, "docmost")
|
|
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva") {
|
|
t.Errorf("nil backupMgr: redirect = %q", loc)
|
|
}
|
|
}
|
|
|
|
// TestR103_FileRestoreRefusalPointsAtTheActionThatWorks — the R-103 split. An app with no file legs
|
|
// but a restorable unit gets the message that names the button beside it, NOT the one that sends it
|
|
// to another page.
|
|
func TestR103_FileRestoreRefusalPointsAtTheActionThatWorks(t *testing.T) {
|
|
s, _ := newR103Server(t, true)
|
|
rec := postTier2Restore(t, s, "app")
|
|
loc := rec.Header().Get("Location")
|
|
if !strings.Contains(loc, url.QueryEscape(tier2UnitAvailableMsg)) {
|
|
t.Errorf("refusal flash = %q, want tier2UnitAvailableMsg", loc)
|
|
}
|
|
if strings.Contains(loc, url.QueryEscape(tier2NoCoverageMsg)) {
|
|
t.Error("the old dead-end message is still shown for a copy restorable here")
|
|
}
|
|
}
|