v0.275.0: a backup's data and its version travel together (R-696, 07 §6.6, D4 option A); R-695, R-691, R-694
gates / gates (push) Successful in 23s
gates / gates (push) Successful in 23s
The unit's data files are stamped with the versions that wrote them; the capture keeps the definition the data belongs to; a restore never starts data under another version's definition (unit restores refuse a mismatch; the off-site restore writes the snapshot's definition); every tier's time is its data's; the conversion-copy release needs a dump on the new engine. File-browser sync single-flight + no empty kept folder (R-695); the kept view joins the folder's owning group, language switch resyncs (R-691); a restore-generated login is not shown as the password (R-694). Red-proofs in felhom.eu/documentation/audits/version-travel-2026-09-26/. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestA3_RestoredVersionSentence — the FIRST sentence after a restore of an older version (v0.275.0,
|
||||
// `07` §6.6, D4 option A), both languages, and its absence when nothing changed. The brief's wording is
|
||||
// the contract: which backup, which version, and what happens next.
|
||||
func TestA3_RestoredVersionSentence(t *testing.T) {
|
||||
s := noteServer(t)
|
||||
at := time.Date(2026, 9, 26, 7, 29, 45, 0, time.UTC)
|
||||
pins := []string{"docmost/docmost:0.96.0@sha256:b5", "postgres:16-alpine", "redis:7-alpine"}
|
||||
|
||||
s.versionPosition = func(string) (bool, bool) { return true, true }
|
||||
got := s.restoredVersionPrefix("docmost", true, pins, at)
|
||||
for _, want := range []string{"A(z) docmost visszaállt a(z) 2026-09-26 ", "-i mentésből", "docmost:0.96.0, postgres:16-alpine, redis:7-alpine verzióra", "lépésenként hozza naprakészre"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("hu: missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(got, " ") {
|
||||
t.Errorf("the prefix must end with a space before the outcome sentence: %q", got)
|
||||
}
|
||||
if err := s.settings.SetLanguage("en"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = s.restoredVersionPrefix("docmost", true, pins, at)
|
||||
if !strings.Contains(got, "docmost is back from the backup of 2026-09-26 ") || !strings.Contains(got, "one step at a time") {
|
||||
t.Errorf("en: %q", got)
|
||||
}
|
||||
|
||||
// No tested step leads on from the restored version: say the box will not update it by itself.
|
||||
s.versionPosition = func(string) (bool, bool) { return true, false }
|
||||
if got := s.restoredVersionPrefix("docmost", true, pins, at); !strings.Contains(got, "does not update it by itself") {
|
||||
t.Errorf("no ladder step: %q", got)
|
||||
}
|
||||
// Nothing changed, or the app is not behind: no sentence.
|
||||
if got := s.restoredVersionPrefix("docmost", false, pins, at); got != "" {
|
||||
t.Errorf("unchanged version: %q", got)
|
||||
}
|
||||
s.versionPosition = func(string) (bool, bool) { return false, false }
|
||||
if got := s.restoredVersionPrefix("docmost", true, pins, at); got != "" {
|
||||
t.Errorf("not behind: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -487,7 +487,17 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
data["AutoFieldValues"] = autoFieldValues
|
||||
// For deployed apps, pass stored field values (decrypted) so fields show current values
|
||||
if alreadyDeployed && decryptedEnv != nil {
|
||||
// R-694 (v0.275.0): a login a restore GENERATED is not the app's login (its login came back with
|
||||
// the data) — its value is never rendered, and the field says to use the old password.
|
||||
restored := map[string]bool{}
|
||||
if appCfg != nil {
|
||||
for _, n := range appCfg.RestoredLogins {
|
||||
restored[n] = true
|
||||
delete(decryptedEnv, n)
|
||||
}
|
||||
}
|
||||
data["DeployedFieldValues"] = decryptedEnv
|
||||
data["RestoredLogins"] = restored
|
||||
}
|
||||
// R-351 SCENARIO A — an app being reinstalled so its data can come back should not ask the
|
||||
// customer to remember what their own backup already recorded. The address and the data folder
|
||||
@@ -1723,7 +1733,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// The customer reads it as "my data is back". The snapshot id is dropped from the sentence
|
||||
// deliberately: it identified WHICH backup ran and told the customer nothing about what came out
|
||||
// of it, which is the question the sentence exists to answer.
|
||||
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res))
|
||||
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(stackName, res.VersionChanged, res.DataPins, res.DataAt)+s.unitRestoreOutcomeMsg(stackName, res))
|
||||
}()
|
||||
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
|
||||
}
|
||||
@@ -1780,6 +1790,39 @@ func (s *Server) unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult)
|
||||
return s.note(unitRestoreSettingsOnlyKey, app)
|
||||
}
|
||||
|
||||
// restoredVersionPrefix is the FIRST sentence after a restore that brought an app back at an older
|
||||
// version (v0.275.0, `07` §6.6 "Which version a restore brings back", D4 option A): which backup, which
|
||||
// version, and what happens next — the box climbs it one tested step at a time, or, when no tested step
|
||||
// leads on from that version, that the box will not update it by itself (a person's press would jump to
|
||||
// the catalog's current definition, which is not a tested step). "" when the version did not change or
|
||||
// the app is not behind. Pinned by TestA3_RestoredVersionSentence.
|
||||
func (s *Server) restoredVersionPrefix(app string, changed bool, pins []string, at time.Time) string {
|
||||
if !changed || len(pins) == 0 {
|
||||
return ""
|
||||
}
|
||||
pos := s.versionPosition
|
||||
if pos == nil {
|
||||
if s.stackMgr == nil {
|
||||
return ""
|
||||
}
|
||||
pos = s.stackMgr.RestoredVersionPosition
|
||||
}
|
||||
behind, climbable := pos(app)
|
||||
if !behind {
|
||||
return ""
|
||||
}
|
||||
key := restoredOlderVersionKey
|
||||
if !climbable {
|
||||
key = restoredOlderVersionNoStepKey
|
||||
}
|
||||
return s.note(key, app, at.In(getTimezone()).Format("2006-01-02 15:04"), backup.PinsVersion(pins)) + " "
|
||||
}
|
||||
|
||||
const (
|
||||
restoredOlderVersionKey = "note.restore.older_version"
|
||||
restoredOlderVersionNoStepKey = "note.restore.older_version_no_step"
|
||||
)
|
||||
|
||||
// R-353 customer-facing strings. Named constants, not inlined, because each is asserted verbatim by
|
||||
// r353_unit_outcome_test.go — a silent edit to any of them is how an honest message drifts back into a
|
||||
// comforting one, which is the exact history of the sentence they replace.
|
||||
@@ -2082,7 +2125,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
|
||||
s.logger.Printf("[INFO] [web] Tier-2 whole restore completed (async): stack=%s in %s (files restored %d, replaced %d, kept-newer %d, unchanged %d; volumes %d/%d, dbs %d/%d)",
|
||||
stackName, time.Since(start), res.Files.Restored, res.Files.Replaced, res.Files.KeptNewer, res.Files.Unchanged,
|
||||
res.Unit.VolumesReplayed, res.Unit.ManifestVolumes, res.Unit.DBsReplayed, res.Unit.ManifestDBs)
|
||||
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res.Unit)+" "+files)
|
||||
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(stackName, res.Unit.VersionChanged, res.Unit.DataPins, res.Unit.DataAt)+s.unitRestoreOutcomeMsg(stackName, res.Unit)+" "+files)
|
||||
}()
|
||||
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash", "flash.restore.full_started"), http.StatusFound)
|
||||
return
|
||||
@@ -2120,7 +2163,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
|
||||
// second thing to keep honest. What IS added is which copy it came from and how old that copy
|
||||
// is: this action overwrote the customer's live data, and the sentence they are left with has
|
||||
// to say what it overwrote it with (Scenario E).
|
||||
msg := s.unitRestoreOutcomeMsg(stackName, res)
|
||||
msg := s.restoredVersionPrefix(stackName, res.VersionChanged, res.DataPins, res.DataAt) + s.unitRestoreOutcomeMsg(stackName, res)
|
||||
if src := s.tier2UnitSourceMsg(cov); src != "" {
|
||||
msg += " " + src
|
||||
}
|
||||
@@ -3350,10 +3393,43 @@ func skipFileBrowserPath(path string, isMount func(string) bool) bool {
|
||||
}
|
||||
|
||||
func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
// R-695 (v0.275.0): SINGLE-FLIGHT. A request is covered by any sync that STARTS reading the state
|
||||
// after the request was made: a caller that waited behind a running sync returns as soon as one
|
||||
// such sync has finished, instead of each queued caller restarting the file browser again. Measured
|
||||
// 2026-09-25 on 9202: two kept-data Deletes in the same second each ran a sync; one restarted the
|
||||
// file browser with a bind list read before the second Delete, Docker recreated the deleted folder
|
||||
// empty, and the next sync listed and bound it again.
|
||||
s.fbReqMu.Lock()
|
||||
s.fbReqGen++
|
||||
mine := s.fbReqGen
|
||||
s.fbReqMu.Unlock()
|
||||
|
||||
// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
|
||||
s.fileBrowserMu.Lock()
|
||||
defer s.fileBrowserMu.Unlock()
|
||||
|
||||
s.fbReqMu.Lock()
|
||||
if !resetDBOnChange && s.fbDoneGen >= mine {
|
||||
s.fbReqMu.Unlock()
|
||||
if s.cfg != nil && s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] FileBrowser sync request %d already covered by a sync that read the state after it", mine)
|
||||
}
|
||||
return
|
||||
}
|
||||
covers := s.fbReqGen // every request made before THIS read of the state is covered by this sync
|
||||
s.fbReqMu.Unlock()
|
||||
defer func() {
|
||||
s.fbReqMu.Lock()
|
||||
if covers > s.fbDoneGen {
|
||||
s.fbDoneGen = covers
|
||||
}
|
||||
s.fbReqMu.Unlock()
|
||||
}()
|
||||
if s.syncFileBrowserHook != nil {
|
||||
s.syncFileBrowserHook()
|
||||
return
|
||||
}
|
||||
|
||||
stackDir := "/opt/docker/stacks/filebrowser"
|
||||
composePath := stackDir + "/docker-compose.yml"
|
||||
|
||||
@@ -3407,9 +3483,13 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
// `09` §3 decision 36: the read-only „Megőrzött adatok" source — one `:ro` bind per kept item, and
|
||||
// the source only when there is at least one (a source with no mount is a broken sidebar entry, R-67).
|
||||
keptLabel := ""
|
||||
if kb := s.keptFileBrowserBinds(); len(kb) > 0 {
|
||||
storageMounts = append(storageMounts, kb...)
|
||||
keptLabel = s.msgLang(s.boxLang(), "kept.fb_source")
|
||||
var keptGroups []int
|
||||
if s.stackMgr != nil {
|
||||
if items := s.stackMgr.ListKept(s.keptDrives()); len(items) > 0 {
|
||||
storageMounts = append(storageMounts, keptBindLines(items)...)
|
||||
keptLabel = s.msgLang(s.boxLang(), "kept.fb_source")
|
||||
keptGroups = keptReadGroups(items, statOwner)
|
||||
}
|
||||
}
|
||||
|
||||
configPath := stackDir + "/config.yaml"
|
||||
@@ -3436,7 +3516,7 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
}
|
||||
|
||||
// Generate and write compose (includes config.yaml mount)
|
||||
compose := generateFileBrowserCompose(domain, storageMounts)
|
||||
compose := infra.RenderFileBrowserCompose(domain, storageMounts, keptGroups...)
|
||||
if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser compose: %v", err)
|
||||
return
|
||||
@@ -3574,13 +3654,6 @@ func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool {
|
||||
return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose)
|
||||
}
|
||||
|
||||
// generateFileBrowserCompose returns a FileBrowser docker-compose.yml string with the given domain
|
||||
// and storage volume-mount lines. Delegates to internal/infra (the single source of truth — so the
|
||||
// pinned image and the base-infra bring-up path can never diverge).
|
||||
func generateFileBrowserCompose(domain string, storageMounts []string) string {
|
||||
return infra.RenderFileBrowserCompose(domain, storageMounts)
|
||||
}
|
||||
|
||||
// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
|
||||
// registered storage path. Delegates to internal/infra (single source of truth).
|
||||
func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {
|
||||
|
||||
@@ -175,6 +175,13 @@ func i18nCasesA() []i18nCase {
|
||||
{"deploy_deployed_stopped", "deploy", func() map[string]interface{} {
|
||||
return i18nDeployData(true, stacks.StateStopped, 2)
|
||||
}},
|
||||
// v0.275.0 (R-694): a login the restore generated — no value, the "use your old password" hint.
|
||||
{"deploy_deployed_restored_login", "deploy", func() map[string]interface{} {
|
||||
d := i18nDeployData(true, stacks.StateRunning, 1)
|
||||
d["DeployedFieldValues"] = map[string]string{"SUBDOMAIN": "paste"}
|
||||
d["RestoredLogins"] = map[string]bool{"ADMIN_PASSWORD": true}
|
||||
return d
|
||||
}},
|
||||
{"settings_system_full", "settings_system", func() map[string]interface{} {
|
||||
d := i18nLayoutData("settings", "Rendszer beállítások")
|
||||
d["CustomerID"], d["CustomerDomain"] = "test-customer", "example.hu"
|
||||
|
||||
@@ -270,6 +270,14 @@ func (s *Server) languageSwitchHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] language switch: household language set to %s", lang)
|
||||
s.reportTriggerNow() // the hub learns the language on the next report, in seconds rather than minutes
|
||||
// R-691 (v0.275.0): the file browser's „Megőrzött adatok" / "Kept data" source is named in the box's
|
||||
// language when the sync writes its config — so the sync runs now, not at the next unrelated change.
|
||||
// A no-op when the box keeps no data (the source is absent) and when nothing changed.
|
||||
if s.langSwitchSync != nil {
|
||||
s.langSwitchSync()
|
||||
} else {
|
||||
go s.SyncFileBrowserMounts()
|
||||
}
|
||||
http.Redirect(w, r, stripLangQuery(redirectBackTo(r, "/launcher")), http.StatusFound)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ package web
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
@@ -190,15 +193,6 @@ func (s *Server) keptLoadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
|
||||
}
|
||||
|
||||
// keptFileBrowserBinds are the read-only binds of the „Megőrzött adatok" source: one per listed item,
|
||||
// `:ro`, under /srv/<source dir> — never a live app's folder (ListKept never lists one).
|
||||
func (s *Server) keptFileBrowserBinds() []string {
|
||||
if s.stackMgr == nil {
|
||||
return nil
|
||||
}
|
||||
return keptBindLines(s.stackMgr.ListKept(s.keptDrives()))
|
||||
}
|
||||
|
||||
// keptBindLines renders the compose bind lines — each READ-ONLY. Pinned by TestKept_FileBrowserBindsAreReadOnly.
|
||||
func keptBindLines(items []stacks.KeptItem) []string {
|
||||
var out []string
|
||||
@@ -207,3 +201,40 @@ func keptBindLines(items []stacks.KeptItem) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keptReadGroups is the R-691 rule — decided by CC unattended 2026-09-26, operator may reverse (`07` §6.5):
|
||||
// the read-only view reads a kept folder another user owns by joining that folder's OWNING GROUP, never by
|
||||
// changing the household's files or their permissions (nextcloud checks its data folder's mode after a
|
||||
// Load). A group is added only when the folder is group-READABLE and the group is neither root's (0 — it
|
||||
// would reach every root-group file in the view's other mounts) nor the view's own (1000). The kept binds
|
||||
// are `:ro`, so the added group cannot write kept data. Sorted, unique. Pinned by TestR691_KeptReadGroups.
|
||||
func keptReadGroups(items []stacks.KeptItem, owner func(path string) (gid int, mode os.FileMode, ok bool)) []int {
|
||||
seen := map[int]bool{}
|
||||
var out []int
|
||||
for _, it := range items {
|
||||
gid, mode, ok := owner(it.Path)
|
||||
if !ok || gid == 0 || gid == fileBrowserUID || mode&0o040 == 0 || seen[gid] {
|
||||
continue
|
||||
}
|
||||
seen[gid] = true
|
||||
out = append(out, gid)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// fileBrowserUID is the uid:gid the file-browser image runs as (gtstef/filebrowser: `filebrowser`, 1000).
|
||||
const fileBrowserUID = 1000
|
||||
|
||||
// statOwner is keptReadGroups' production owner reader.
|
||||
func statOwner(path string) (int, os.FileMode, bool) {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return int(st.Gid), fi.Mode().Perm(), true
|
||||
}
|
||||
|
||||
@@ -471,7 +471,7 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] off-box reconstitute %s completed (async): files=%d dbs=%d snapshot=%s",
|
||||
app, res.FilesPlaced, res.DBsReplayed, res.SnapshotID)
|
||||
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res, s.boxLang()))
|
||||
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(app, res.VersionChanged, res.DataPins, res.DataAt)+reconstituteOutcomeMsg(app, res, s.boxLang()))
|
||||
}()
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.full_restore_started", false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-691 (v0.275.0) — the read-only „Megőrzött adatok" view could not open nextcloud's kept folder
|
||||
// (`www-data` 33, mode 0770; the view runs as 1000). The rule: join the folder's OWNING GROUP when it
|
||||
// is group-readable — never root's, never the view's own — and change nothing on the household's files.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): return nil from keptReadGroups — the compose then carries no
|
||||
// group_add and this fails at "group_add missing".
|
||||
func TestR691_KeptReadGroups(t *testing.T) {
|
||||
owners := map[string]struct {
|
||||
gid int
|
||||
mode os.FileMode
|
||||
}{
|
||||
"/d/kept/nextcloud/2026-09-25_141014": {33, 0o770}, // the measured case → 33
|
||||
"/d/appdata/nextcloud": {33, 0o770}, // the same group once
|
||||
"/d/kept/romm/x": {1000, 0o755}, // the view's own group: nothing to add
|
||||
"/d/kept/secret/x": {0, 0o750}, // root's group: NEVER
|
||||
"/d/kept/private/x": {999, 0o700}, // not group-readable: adding it would not help
|
||||
"/d/kept/jellyfin/x": {911, 0o750}, // another readable group → 911
|
||||
}
|
||||
var items []stacks.KeptItem
|
||||
for p := range owners {
|
||||
items = append(items, stacks.KeptItem{Path: p})
|
||||
}
|
||||
got := keptReadGroups(items, func(p string) (int, os.FileMode, bool) {
|
||||
o, ok := owners[p]
|
||||
return o.gid, o.mode, ok
|
||||
})
|
||||
if len(got) != 2 || got[0] != 33 || got[1] != 911 {
|
||||
t.Fatalf("groups = %v, want [33 911]", got)
|
||||
}
|
||||
compose := infra.RenderFileBrowserCompose("example.hu", keptBindLines([]stacks.KeptItem{{Path: "/d/kept/nextcloud/2026-09-25_141014", App: "nextcloud"}}), got...)
|
||||
if !strings.Contains(compose, "group_add:\n - \"33\"\n - \"911\"") {
|
||||
t.Fatalf("group_add missing or malformed:\n%s", compose)
|
||||
}
|
||||
if !strings.Contains(compose, ":/srv/"+infra.FileBrowserKeptMount+"/") || !strings.Contains(compose, ":ro") {
|
||||
t.Fatalf("the kept bind must stay read-only:\n%s", compose)
|
||||
}
|
||||
// No kept groups → the compose is exactly what it was before v0.275.0.
|
||||
if strings.Contains(infra.RenderFileBrowserCompose("example.hu", nil), "group_add") {
|
||||
t.Fatal("a box with no kept data got a group_add")
|
||||
}
|
||||
}
|
||||
|
||||
// The source's name follows the box's language: switching the language triggers the file-browser sync
|
||||
// that writes it (before, it waited for the next unrelated change — seen on 9202 2026-09-25).
|
||||
func TestR691_ALanguageSwitchResyncsTheFileBrowser(t *testing.T) {
|
||||
s := noteServer(t)
|
||||
called := 0
|
||||
s.langSwitchSync = func() { called++ }
|
||||
req := httptest.NewRequest(http.MethodPost, "/settings/language", strings.NewReader(url.Values{"lang": {"en"}}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
s.languageSwitchHandler(rec, req)
|
||||
if rec.Code != http.StatusFound || called != 1 {
|
||||
t.Fatalf("status %d, file-browser syncs %d — want a redirect and one sync", rec.Code, called)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-694 (v0.275.0) — a deployed app whose admin login was GENERATED by a restore: the page shows no value
|
||||
// for it and says the old password is the one that works; an app without that record renders as before.
|
||||
// Rendered through the production template path (renderI18nCase), both languages.
|
||||
func TestR694_TheDeployPageDoesNotOfferAGeneratedLogin(t *testing.T) {
|
||||
s := i18nTestServer(t)
|
||||
base := func() map[string]interface{} { return i18nDeployData(true, stacks.StateRunning, 1) }
|
||||
restored := func() map[string]interface{} {
|
||||
d := base()
|
||||
d["DeployedFieldValues"] = map[string]string{"SUBDOMAIN": "paste"} // the handler removed the value
|
||||
d["RestoredLogins"] = map[string]bool{"ADMIN_PASSWORD": true}
|
||||
return d
|
||||
}
|
||||
hu := renderI18nCase(t, s, "hu", i18nCase{"r694", "deploy", restored})
|
||||
if !strings.Contains(hu, "Mentésből töltötted vissza") || strings.Contains(hu, "rejtett érték") || strings.Contains(hu, "Telepítéskor beállított kezdeti jelszó") {
|
||||
t.Fatalf("hu page does not say to use the old password, or still shows a value")
|
||||
}
|
||||
en := renderI18nCase(t, s, "en", i18nCase{"r694", "deploy", restored})
|
||||
if !strings.Contains(en, "Restored from a backup: log in with the password that was valid when the backup was taken") {
|
||||
t.Fatal("en page lacks the sentence")
|
||||
}
|
||||
plain := renderI18nCase(t, s, "hu", i18nCase{"r694-plain", "deploy", base})
|
||||
if strings.Contains(plain, "Mentésből töltötted vissza") || !strings.Contains(plain, "Telepítéskor beállított kezdeti jelszó") {
|
||||
t.Fatal("an app without restored_logins changed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-695 (v0.275.0) — the file-browser sync is single-flight: callers that queued behind a running sync
|
||||
// are covered by ONE sync that read the state after they asked, not by one restart each; a caller whose
|
||||
// request came after a sync began reading is never dropped.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): remove the `fbDoneGen >= mine` early return — five queued callers
|
||||
// then run five syncs.
|
||||
func TestR695_QueuedSyncsCoalesceIntoOne(t *testing.T) {
|
||||
s := &Server{logger: log.New(io.Discard, "", 0)}
|
||||
var runs int32
|
||||
release := make(chan struct{})
|
||||
first := true
|
||||
var fmu sync.Mutex
|
||||
s.syncFileBrowserHook = func() {
|
||||
atomic.AddInt32(&runs, 1)
|
||||
fmu.Lock()
|
||||
f := first
|
||||
first = false
|
||||
fmu.Unlock()
|
||||
if f {
|
||||
<-release // the first sync is slow: everything below queues behind it
|
||||
}
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); s.SyncFileBrowserMounts() }()
|
||||
time.Sleep(50 * time.Millisecond) // the first sync holds the lock
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); s.SyncFileBrowserMounts() }()
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond) // all five are queued
|
||||
close(release)
|
||||
wg.Wait()
|
||||
if got := atomic.LoadInt32(&runs); got != 2 {
|
||||
t.Fatalf("%d syncs ran for 6 requests (1 running + 5 queued) — want 2: the running one and ONE that re-reads for the queue", got)
|
||||
}
|
||||
// A request after everything finished is never dropped.
|
||||
s.SyncFileBrowserMounts()
|
||||
if got := atomic.LoadInt32(&runs); got != 3 {
|
||||
t.Fatalf("a new request after the queue drained ran %d syncs in total, want 3", got)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,10 @@ type Server struct {
|
||||
tmplByLang map[string]*template.Template
|
||||
i18n *i18n.Bundle
|
||||
|
||||
// versionPosition (v0.275.0) — where a just-restored app stands against the catalog; nil → the stack
|
||||
// manager's RestoredVersionPosition. A seam so the restore sentence is testable without a catalog.
|
||||
versionPosition func(app string) (behind, climbable bool)
|
||||
|
||||
sessions map[string]*session
|
||||
sessionsMu sync.RWMutex
|
||||
loginAttempts map[string]*loginAttempt
|
||||
@@ -73,6 +77,15 @@ type Server struct {
|
||||
|
||||
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
|
||||
fileBrowserMu sync.Mutex
|
||||
// fbReqMu guards the file-browser sync's single-flight counters (R-695, v0.275.0): fbReqGen counts
|
||||
// requests, fbDoneGen is the newest request a finished sync is known to have covered.
|
||||
fbReqMu sync.Mutex
|
||||
fbReqGen uint64
|
||||
fbDoneGen uint64
|
||||
// syncFileBrowserHook replaces the sync's body in tests (the single-flight is the unit under test).
|
||||
syncFileBrowserHook func()
|
||||
// langSwitchSync replaces the language switch's file-browser sync in tests (R-691).
|
||||
langSwitchSync func()
|
||||
|
||||
// Shared agent local-API client (built once, reused). cfg.LocalAPI is static per process (a
|
||||
// config-apply triggers a graceful self-restart), so the client is memoized via agentCliOnce —
|
||||
|
||||
@@ -577,7 +577,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
{{if $.AlreadyDeployed}}
|
||||
<span class="form-hint">{{T "deploy.telepiteskor_beallitott_kezdeti_jelszo_h"}}</span>
|
||||
<span class="form-hint">{{if and $.RestoredLogins (index $.RestoredLogins .EnvVar)}}{{T "deploy.login_from_backup"}}{{else}}{{T "deploy.telepiteskor_beallitott_kezdeti_jelszo_h"}}{{end}}</span>
|
||||
{{else}}
|
||||
<div class="input-with-button" style="margin-top:.25rem">
|
||||
<input type="password" id="field-confirm-{{.EnvVar}}"
|
||||
|
||||
+1704
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user