1040cfe225
Sessions are keyed by sha256(cookie) and persisted to dashboard-sessions.json
(0600, tmp+fsync+rename) in the data dir: fingerprint, expiry, CSRF token.
Loaded in NewServer; expired rows dropped at load and save. Logout and
invalidateAllSessions (password change, claim reset) write the file at once.
Corrupt/unreadable file = start with no sessions (never fatal).
Red-proof: with load/save as no-ops the restart test fails ('the old cookie
no longer signs in'); with the raw token as the key the file test fails
('the sessions file holds the cookie value').
Also: TestR650_NoBareDockerExec skips a non-.go file that vanished mid-walk
(a parallel stacks test's update-journal.json.tmp raced it in a full run).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
198 lines
6.2 KiB
Go
198 lines
6.2 KiB
Go
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")
|
|
}
|
|
}
|