From 6ed223886de19265aa38f763b59bad86d56213f7 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 17 Oct 2026 09:56:12 +0200 Subject: [PATCH] R-1112: open the family store when the controller starts Found on demo-hp with 0.325.0-rc.1: the store (and so the conversion of an old family.json) opened only on the first family request; a box nobody visits through a family gate kept the session values. OpenFamilyStore from main, one INFO line with the count. TestR1112_StartConvertsTheFamilyFile, TestR1112_MainOpensTheFamilyStore, red-proved. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 7 +++-- controller/cmd/controller/main.go | 3 ++ controller/cmd/controller/r856_wiring_test.go | 21 +++++++++++++ controller/internal/family/family.go | 11 +++++++ controller/internal/web/family_gate.go | 11 +++++++ controller/internal/web/r1111_r1113_test.go | 31 +++++++++++++++++++ 6 files changed, 82 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab43ea7..c5a9567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,11 @@ burndown 3 (felhom.eu `REPORT-burndown-3-2026-10-17.md`). gate stays shut for members, the household passes). Family app cookies are derived from the fingerprint, so a cookie minted before this version is refused ONCE and the browser passes through `/__family/start` again (no new sign-in while the family session holds). A rollback below this version signs family members out once (the old reader does - not know `fp`). Tests `TestR1112_FamilyFileHoldsNoUsableSession`, `TestR1112_OldFamilyFileIsConvertedOnLoad`, - `TestFamily_OldFileThatCannotBeRewrittenIsAnError`, red-proved. + not know `fp`). **The store is opened when the controller starts** (`webServer.OpenFamilyStore`, one INFO line with + the count): found live on demo-hp with rc.1 — the store opened only on the first family request, so a box nobody visits + through a family gate kept the old file. Tests `TestR1112_FamilyFileHoldsNoUsableSession`, + `TestR1112_OldFamilyFileIsConvertedOnLoad`, `TestFamily_OldFileThatCannotBeRewrittenIsAnError`, + `TestR1112_StartConvertsTheFamilyFile`, `TestR1112_MainOpensTheFamilyStore`, each red-proved. - **R-1113** — `/__family/start` with the household's dashboard session reuses the household's live family session (`Store.HouseholdSession`; a new one only when less than half of the 30 days is left), instead of one more per pass. `TestR1113_HouseholdPassesLeaveOneSession` (6 passes → 1 session), red-proved. diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 2602b52..a488fbe 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -2058,6 +2058,9 @@ func main() { // Browser .fab upload (v0.128.0): upload state is in-memory, so a restart strands the .part — // GC stray part files in every registered drive's exports dir at startup. webServer.CleanupStaleUploadParts() + // R-1112: open (and convert) the family list now, not on the first family request — a box nobody visits through a + // family gate would otherwise keep session values on disk. + webServer.OpenFamilyStore() // Escrow wizard (v0.127.0): the Scenario-F stale-blob flag feeds the Távoli mentés card. if escrowConfirmer != nil { webServer.SetEscrowStale(escrowConfirmer.StaleBlob) diff --git a/controller/cmd/controller/r856_wiring_test.go b/controller/cmd/controller/r856_wiring_test.go index 1adff0a..c51f561 100644 --- a/controller/cmd/controller/r856_wiring_test.go +++ b/controller/cmd/controller/r856_wiring_test.go @@ -62,3 +62,24 @@ func TestR856_NormalGraceIsTheDeadAppBootGrace(t *testing.T) { t.Errorf("crashboot.NormalGrace = %s, deadAppBootGrace = %s — a normal boot must keep today's grace", crashboot.NormalGrace, deadAppBootGrace) } } + +// R-1112: main opens the family store at start (webServer.OpenFamilyStore), so an old family.json is converted on every +// box — not only on one where someone passes a family gate. Red-proof: remove the call from main.go → this fails. +func TestR1112_MainOpensTheFamilyStore(t *testing.T) { + f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0) + if err != nil { + t.Fatal(err) + } + found := false + ast.Inspect(f, func(n ast.Node) bool { + if call, ok := n.(*ast.CallExpr); ok { + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "OpenFamilyStore" { + found = true + } + } + return true + }) + if !found { + t.Fatal("main.go does not call OpenFamilyStore — an old family.json keeps its session values until a family request") + } +} diff --git a/controller/internal/family/family.go b/controller/internal/family/family.go index 98380f1..1e4cd7a 100644 --- a/controller/internal/family/family.go +++ b/controller/internal/family/family.go @@ -88,6 +88,8 @@ type Store struct { mu sync.Mutex members map[string]*Member sessions map[string]Session + + converted int // sessions Open found stored the old way (R-1112) — ConvertedAtOpen, for the start-up log line } var ( @@ -134,6 +136,7 @@ func Open(dir string) (*Store, error) { } s.sessions[se.FP] = se } + s.converted = converted if converted > 0 { s.mu.Lock() err := s.saveLocked() @@ -306,6 +309,14 @@ func (s *Store) Verify(name, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } +// ConvertedAtOpen is how many sessions Open found stored the old way and rewrote as fingerprints (0 for a nil store). +func (s *Store) ConvertedAtOpen() int { + if s == nil { + return 0 + } + return s.converted +} + // NewSession starts a session for member ("" = the household) and returns its cookie value — the only time the value // exists outside the browser; the store keeps its Fingerprint. It refuses a member who does not exist. func (s *Store) NewSession(member string) (string, error) { diff --git a/controller/internal/web/family_gate.go b/controller/internal/web/family_gate.go index 84e0ffb..e9c6d17 100644 --- a/controller/internal/web/family_gate.go +++ b/controller/internal/web/family_gate.go @@ -93,6 +93,17 @@ func (s *Server) familyStore() *family.Store { return s.fam.store } +// OpenFamilyStore opens the family list when the controller starts (R-1112). A family.json written before v0.325.0 +// holds the session values themselves, and the store converts them to fingerprints when it is OPENED — which was +// lazily, on the first family request: on a box where nobody passes a family gate, never (measured on demo-hp 9201, +// 2026-10-17: rc.1 up, family.json still in the old shape). Pinned by TestR1112_StartConvertsTheFamilyFile and +// cmd/controller TestR1112_MainOpensTheFamilyStore. +func (s *Server) OpenFamilyStore() { + if n := s.familyStore().ConvertedAtOpen(); n > 0 { + s.logger.Printf("[INFO] [web] family store: %d session(s) stored the old way were rewritten as fingerprints (R-1112)", n) + } +} + // familyReserve checks whether this visitor or this name is out of tries and, when neither is, counts this try // against both — one step under one lock (R-983, v0.324.0), so parallel tries cannot all pass a check that none of // them has counted yet. Windows slide; a success clears both (familyCleared). It reports whether the try may go ahead. diff --git a/controller/internal/web/r1111_r1113_test.go b/controller/internal/web/r1111_r1113_test.go index 7577be4..854d63a 100644 --- a/controller/internal/web/r1111_r1113_test.go +++ b/controller/internal/web/r1111_r1113_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "net/http/httptest" "net/url" @@ -17,6 +18,7 @@ import ( "golang.org/x/crypto/bcrypt" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/family" ) @@ -308,3 +310,32 @@ func TestR1113_HouseholdPassesLeaveOneSession(t *testing.T) { t.Fatalf("after the older one expired: %d live household sessions, want 1", live) } } + +// R-1112 at START: the conversion happens when the dashboard starts, not on the first family request (measured on demo-hp +// 9201 with rc.1: up, family.json still in the old shape, because nobody had passed a family gate). OpenFamilyStore is +// what main calls; the conversion is logged by count, never a value. +// Red-proof: OpenFamilyStore with an empty body → the old values stay in the file and this fails. +func TestR1112_StartConvertsTheFamilyFile(t *testing.T) { + dir := t.TempDir() + household := "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978" + exp := time.Now().Add(20 * 24 * time.Hour).UTC().Format(time.RFC3339) + path := filepath.Join(dir, "family.json") + if err := os.WriteFile(path, []byte(`{"members":[],"sessions":[{"id":"`+household+`","member":"","gen":0,"exp":"`+exp+`"}]}`), 0o600); err != nil { + t.Fatal(err) + } + cfg := config.Default() + cfg.Paths.DataDir = dir + var logs strings.Builder + s := &Server{cfg: cfg, logger: log.New(&logs, "", 0)} + s.OpenFamilyStore() + b, _ := os.ReadFile(path) + if strings.Contains(string(b), household) { + t.Fatalf("family.json still holds the session value after the start: %s", b) + } + if !strings.Contains(logs.String(), "1 session(s) stored the old way were rewritten as fingerprints") { + t.Errorf("the conversion must be logged by count, got %q", logs.String()) + } + if strings.Contains(logs.String(), household) { + t.Error("the log line carries the session value") + } +}