R-100: record the offsite last-SUCCESS anchor (v0.181.0)

LastRun records an attempt, not a result. New OffboxTarget.LastSuccess, set only on the
success branch via the pure offboxAnchorAfterRun rule, carried to the hub as last_success.
Closes two silent-wipe sites (settings save, hub re-apply).
This commit is contained in:
2026-07-28 13:12:37 +02:00
parent 4056feccee
commit e000e201af
6 changed files with 298 additions and 6 deletions
@@ -0,0 +1,69 @@
package web
import (
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-100 — an ordinary settings save must not erase the staleness anchor.
//
// offboxConfigHandler builds a FRESH OffboxTarget from the form and copies runtime status across from
// the previous one, field by field. Every field omitted from that copy is silently zeroed. LastSuccess
// is the hub's staleness anchor, so omitting it would reset an established tier to "never succeeded"
// every time the customer edited the host or the path — and the reset would be invisible until the
// hub's verdict changed days later.
//
// This is the "seam built but never wired" shape: the field exists, the writer sets it, and a routine
// unrelated code path throws it away.
//
// RED-PROOF: remove `tgt.LastSuccess = prev.LastSuccess` from offboxConfigHandler → this fails with
// "a settings save erased LastSuccess".
func TestOffboxEdit_PreservesLastSuccess(t *testing.T) {
s, sett, _ := newOffboxWebServer(t)
const anchor = "2026-07-26T02:15:00Z"
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
EscrowState: "escrowed",
LastRun: "2026-07-27T02:15:00Z", // a later FAILED attempt
LastStatus: "error",
LastSuccess: anchor,
}); err != nil {
t.Fatal(err)
}
// The customer edits the repo path — nothing to do with run history.
form := url.Values{
"enabled": {"on"}, "host": {"nas.local"}, "user": {"felhom"},
"repo_path": {"/srv/repo-moved"}, "port": {"22"},
// First-setup guard: the handler requires both secrets when none are on disk yet.
"ssh_key": {"-----BEGIN OPENSSH PRIVATE KEY-----\nZmFrZQ==\n-----END OPENSSH PRIVATE KEY-----\n"},
"known_hosts": {"nas.local ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAK"},
}
req := httptest.NewRequest("POST", "/backup/offbox/config", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.offboxConfigHandler(rec, req)
// NOTE: the redirect carries a flash about escrow STAGING failing — there is no agent in this
// fixture. The target itself is persisted before that step, which is what this test is about, and
// the repo_path assertion below is the real precondition.
got := sett.GetOffboxTarget()
if got == nil {
t.Fatal("target vanished after the save")
}
if got.RepoPath != "/srv/repo-moved" {
t.Fatalf("precondition: the edit did not apply (repo_path = %q)", got.RepoPath)
}
if got.LastSuccess != anchor {
t.Errorf("a settings save erased LastSuccess (got %q, want %q) — the tier would read as never-succeeded",
got.LastSuccess, anchor)
}
// and the attempt clock is preserved too, as it already was
if got.LastRun != "2026-07-27T02:15:00Z" {
t.Errorf("the save also lost LastRun (got %q)", got.LastRun)
}
}