Files
felhom-controller/controller/internal/web/r103_tier2_action_test.go
T
admin 5270bad76e
gates / gates (push) Successful in 23s
v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 10:15:56 +02:00

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(flashSentence(t, loc), "Mentés nincs beállítva") {
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")
}
}