R-1271 (P2): „use my kept data” never takes a copy dated in the future — a copy more than 10 minutes past the box's clock never counts as the newest (the newest true date wins, tie local); alone it is still offered, said and not recommended. Red-proved. Commit only.
gates / gates (push) Successful in 1m33s

This commit is contained in:
2026-10-25 09:44:56 +01:00
parent 2d71270858
commit bd9f4f7b74
13 changed files with 278 additions and 32 deletions
+9 -1
View File
@@ -20,6 +20,10 @@ type keptChoiceData struct {
UseDesc string `json:"use_desc"` // "" when use is off
UseOff string `json:"use_off"` // "" when use is offered
UseOffered bool `json:"use_offered"` // a database copy exists for these files
// UseFuture / UseWarn (R-1271): the only copy is dated in the future — offered, NOT recommended (the page draws
// „use" as the plain button), and the sentence says why.
UseFuture bool `json:"use_future,omitempty"`
UseWarn string `json:"use_warn,omitempty"`
FreshLabel string `json:"fresh_label"`
FreshDesc string `json:"fresh_desc"`
NotBacked string `json:"not_backed_up"`
@@ -71,13 +75,17 @@ func (r *Router) keptDataAtInstall(w http.ResponseWriter, req *http.Request, nam
}
if cp.ok {
data.UseDesc = r.msgLang(lang, "kept.choice.use.desc_from", r.keptCopyName(lang, cp.c))
if cp.c.Future {
data.UseFuture = true
data.UseWarn = r.msgLang(lang, "kept.choice.use.future", localMinute(cp.c.Time))
}
} else {
data.UseOff = r.msgLang(lang, "kept.choice.use_off")
}
switch choice {
case "":
r.logger.Printf("[INFO] [api] Deploy %s: the drive already holds its old data %v (%d bytes) — asking the household (use offered: %v)", name, old, size, cp.ok)
r.logger.Printf("[INFO] [api] Deploy %s: the drive already holds its old data %v (%d bytes) — asking the household (use offered: %v, future-dated: %v)", name, old, size, cp.ok, cp.c.Future)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.Title, Data: data})
return true
case stacks.KeptChoiceUse:
@@ -0,0 +1,86 @@
package api
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"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"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-1271 (brief 2026-10-25 Part 4) — a future-dated off-site copy, the ONLY copy of the kept files: still offered (it
// may be the household's), but the question says its date is in the future and does not recommend it. Same rig as
// TestR691 (the restic runner seam; nothing reaches restic, ssh or Docker).
// COMPANION RED-PROOF (REPORT.md): with keptPick ignoring the future date, use_future stays false and this fails.
func TestR1271_AFutureCopyAloneIsSaidAndNotRecommended(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "cloudapp")
for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp"), cfg.Paths.DataDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644)
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644)
_ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644)
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
if 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)
}
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
if err := bm.WriteOffboxSecrets("KEY", "nas.local ssh-ed25519 AAAA"); err != nil {
t.Fatal(err)
}
snapAt := time.Now().Add(4 * 24 * time.Hour) // the rehearsal box's clock: four days ahead
bm.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
for _, a := range args {
if a == "snapshots" {
return json.Marshal([]map[string]interface{}{{"short_id": "abba81ba", "time": snapAt,
"tags": []string{"cloudapp"}, "paths": []string{drive + "/backups/primary/cloudapp", drive + "/appdata/cloudapp"}}})
}
}
return nil, nil
})
r := &Router{stackMgr: m, backupMgr: bm, logger: log.New(io.Discard, "", 0)}
for lang, want := range map[string]string{"hu": "a jövőben van", "en": "is in the future"} {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang="+lang, nil)
if !r.keptDataAtInstall(w, req, "cloudapp", drive, "") || w.Code != http.StatusConflict {
t.Fatalf("%s: the choice was not asked (code %d)", lang, w.Code)
}
var body map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &body)
data, _ := body["data"].(map[string]interface{})
warn, _ := data["use_warn"].(string)
if data["use_offered"] != true || data["use_future"] != true || !strings.Contains(warn, want) {
t.Fatalf("%s: a future-dated only copy is offered, marked and said: offered=%v future=%v warn=%q", lang, data["use_offered"], data["use_future"], warn)
}
}
}