package web import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-35 (D4, operator ruling 2026-10-08): a dashboard sign-in survives the controller's own restart, and the disk holds // only a fingerprint of each session. Every test builds its servers with NewServer — the call cmd/controller/main.go // makes — so the load is pinned on the production constructor, not on a helper a test calls by hand. // // RED-PROOF (seen 2026-10-08): with loadSessions and saveSessionsLocked reduced to no-ops (today's in-memory-only map), // TestR35_RestartKeepsSession and TestR35_FileHoldsNoToken fail ("restart: the old cookie no longer signs in"; // "no sessions file written"), and the invalidate/logout/expired cases pass vacuously — which is why the restart // case is the positive control for each of them: they assert the SAME cookie worked before they assert it stopped. func newR35Server(t *testing.T, dir string) *Server { t.Helper() lg := log.New(io.Discard, "", 0) sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatalf("settings: %v", err) } cfg := &config.Config{} cfg.Paths.DataDir = dir s := NewServer(cfg, nil, nil, nil, nil, sett, nil, nil, nil, lg, "test") t.Cleanup(s.Close) return s } func TestR35_RestartKeepsSession(t *testing.T) { dir := t.TempDir() s1 := newR35Server(t, dir) tok := s1.createSession() csrf := s1.csrfTokenForSession(tok) if !s1.isValidSession(tok) || csrf == "" { t.Fatal("setup: a fresh session must be valid on the server that made it") } s1.Close() s2 := newR35Server(t, dir) // the restart if !s2.isValidSession(tok) { t.Fatal("restart: the old cookie no longer signs in") } if got := s2.csrfTokenForSession(tok); got != csrf { t.Fatalf("restart: CSRF token changed (%d chars, want the same %d) — every open page's forms would 403", len(got), len(csrf)) } if s2.isValidSession(tok + "0") { t.Fatal("a cookie that was never issued must not sign in") } } func TestR35_FileHoldsNoToken(t *testing.T) { dir := t.TempDir() s := newR35Server(t, dir) tok := s.createSession() b, err := os.ReadFile(filepath.Join(dir, sessionsFileName)) if err != nil { t.Fatalf("no sessions file written: %v", err) } if bytes.Contains(b, []byte(tok)) { t.Fatal("the sessions file holds the cookie value — a stolen archive would sign in") } sum := sha256.Sum256([]byte(tok)) if !bytes.Contains(b, []byte(hex.EncodeToString(sum[:]))) { t.Fatal("the sessions file does not hold the session's fingerprint (positive control)") } fi, err := os.Stat(filepath.Join(dir, sessionsFileName)) if err != nil { t.Fatal(err) } if fi.Mode().Perm() != 0o600 { t.Fatalf("sessions file mode %o, want 600", fi.Mode().Perm()) } // The file ALONE cannot sign in: presenting the fingerprint itself as the cookie is rejected on a restarted server. s.Close() s2 := newR35Server(t, dir) if s2.isValidSession(hex.EncodeToString(sum[:])) { t.Fatal("the fingerprint read from disk signs in as a cookie") } if !s2.isValidSession(tok) { t.Fatal("positive control: the real cookie must still sign in after the restart") } } func TestR35_InvalidateAllEndsSessionAcrossRestart(t *testing.T) { dir := t.TempDir() s1 := newR35Server(t, dir) tok := s1.createSession() s1.Close() s2 := newR35Server(t, dir) if !s2.isValidSession(tok) { t.Fatal("setup: the session must survive one restart first") } s2.invalidateAllSessions() // the password change and the claim reset call this s2.Close() s3 := newR35Server(t, dir) if s3.isValidSession(tok) { t.Fatal("after a password change the old cookie signs in again after a restart") } } func TestR35_LogoutEndsSessionAcrossRestart(t *testing.T) { dir := t.TempDir() s1 := newR35Server(t, dir) tok := s1.createSession() keep := s1.createSession() s1.Close() s2 := newR35Server(t, dir) if !s2.isValidSession(tok) { t.Fatal("setup: the session must survive one restart first") } req := httptest.NewRequest(http.MethodPost, "/logout", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tok}) s2.handleLogout(httptest.NewRecorder(), req) if s2.isValidSession(tok) { t.Fatal("logout: the session is still valid in the same process") } s2.Close() s3 := newR35Server(t, dir) if s3.isValidSession(tok) { t.Fatal("logout: the signed-out cookie signs in again after a restart") } if !s3.isValidSession(keep) { t.Fatal("logout of one browser ended another browser's session") } } func TestR35_ExpiredRowNotLoaded(t *testing.T) { dir := t.TempDir() s1 := newR35Server(t, dir) live := s1.createSession() old := s1.createSession() // Age one session past its expiry, as 7 days would — through the map the server owns, then persisted. s1.sessionsMu.Lock() s1.sessions[sessionFingerprint(old)].expiresAt = time.Now().Add(-time.Minute) _ = s1.saveSessionsLocked() s1.sessionsMu.Unlock() s1.Close() var f sessionsFile b, _ := os.ReadFile(filepath.Join(dir, sessionsFileName)) if err := json.Unmarshal(b, &f); err != nil { t.Fatalf("sessions file: %v", err) } if len(f.Sessions) != 1 { t.Fatalf("an expired row was written to disk: %d rows, want 1", len(f.Sessions)) } s2 := newR35Server(t, dir) if s2.isValidSession(old) { t.Fatal("an expired session signs in after a restart") } if !s2.isValidSession(live) { t.Fatal("positive control: the live session must survive") } s2.sessionsMu.RLock() n := len(s2.sessions) s2.sessionsMu.RUnlock() if n != 1 { t.Fatalf("loaded %d sessions, want 1", n) } } // A corrupt or foreign file never stops the controller and never signs anyone in. func TestR35_CorruptFileStartsEmpty(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, sessionsFileName), []byte("{not json"), 0o600); err != nil { t.Fatal(err) } s := newR35Server(t, dir) s.sessionsMu.RLock() n := len(s.sessions) s.sessionsMu.RUnlock() if n != 0 { t.Fatalf("a corrupt file loaded %d sessions", n) } tok := s.createSession() // and the next save repairs the file s.Close() if !newR35Server(t, dir).isValidSession(tok) { t.Fatal("after a corrupt file, a new session must persist again") } }