package web import ( "io" "log" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/infra" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-1100 — one sign-in for the file manager. Driven through CatchAllMiddleware, the entry traefik's forwardAuth and the // browser both reach: GET /__files/open on the dashboard host, and GET /__felhom_gate/files with traefik's // X-Forwarded-* for files.. Each refusal below was seen to fail with its guard removed (COMPANION RED-PROOFs). func filesHarness(t *testing.T, domain, dataDir string) *Server { t.Helper() cfg := config.Default() cfg.Customer.Domain = domain cfg.Paths.DataDir = dataDir return &Server{cfg: cfg, logger: log.New(io.Discard, "", 0), sessions: map[string]*session{}} } func newFilesServer(t *testing.T) *Server { t.Helper() d := filepath.Join(t.TempDir(), "data") if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } return filesHarness(t, "example.hu", d) } func (s *Server) filesEntry(r *http.Request) *httptest.ResponseRecorder { w := httptest.NewRecorder() s.CatchAllMiddleware(http.NotFoundHandler()).ServeHTTP(w, r) return w } // filesOpen is the household's „Fájlok ↗" press: GET /__files/open on the dashboard host. func filesOpen(s *Server, to string, cookies ...*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, "https://felhom."+s.cfg.Customer.Domain+filesOpenPath+"?"+url.Values{"to": {to}}.Encode(), nil) for _, c := range cookies { r.AddCookie(c) } return s.filesEntry(r) } // filesAsk is traefik's forwardAuth question for a request to host+uri. func filesAsk(s *Server, host, uri, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+filesAuthPath, nil) r.Header.Set("X-Forwarded-Host", host) r.Header.Set("X-Forwarded-Method", http.MethodGet) r.Header.Set("X-Forwarded-Uri", uri) r.Header.Set("Accept", accept) for _, c := range cookies { r.AddCookie(c) } return s.filesEntry(r) } const htmlAccept = "text/html,application/xhtml+xml" func sessionCookie(s *Server) *http.Cookie { return &http.Cookie{Name: sessionCookieName, Value: s.createSession()} } // passFrom returns the /_felhom/sso URI a /__files/open answer sends the browser to ("" = none). func passFrom(t *testing.T, w *httptest.ResponseRecorder, host string) string { t.Helper() loc, err := url.Parse(w.Header().Get("Location")) if err != nil || w.Code != http.StatusFound || loc.Host != host || loc.Path != filesCallbackURI || loc.Query().Get("t") == "" { return "" } return loc.RequestURI() } func filesCookieIn(w *httptest.ResponseRecorder, name string) *http.Cookie { for _, c := range w.Result().Cookies() { if c.Name == name { return c } } return nil } // signIn walks the whole household path and returns the files cookie. func signIn(t *testing.T, s *Server, sess *http.Cookie, to string) *http.Cookie { t.Helper() pass := passFrom(t, filesOpen(s, to, sess), "files.example.hu") if pass == "" { t.Fatal("the dashboard minted no pass for a signed-in household") } w := filesAsk(s, "files.example.hu", pass, htmlAccept) fc := filesCookieIn(w, filesCookieName) if w.Code != http.StatusFound || fc == nil || fc.Value == "" { t.Fatalf("the pass was not swapped for the files cookie: %d %v", w.Code, w.Header()) } return fc } // The household's path: „Fájlok ↗" → the pass → the files cookie → FileBrowser gets the header naming the household's // user. The cookie is host-only (no Domain), HttpOnly, Secure, Lax; the browser lands on the folder it asked for. func TestFilesSSO_R1100_HouseholdOpensTheFolderWithoutALogin(t *testing.T) { s := newFilesServer(t) header := s.filesHeader() if !infra.ValidFileBrowserSSOHeader(header) { t.Fatalf("no secret header name: %q", header) } sess := sessionCookie(s) const folder = "/files/HDD%202/media" pass := passFrom(t, filesOpen(s, folder, sess), "files.example.hu") if pass == "" { t.Fatal("no pass minted") } w := filesAsk(s, "files.example.hu", pass, htmlAccept) fc := filesCookieIn(w, filesCookieName) if w.Code != http.StatusFound || w.Header().Get("Location") != folder { t.Fatalf("the pass answer: %d → %q, want 302 → %s", w.Code, w.Header().Get("Location"), folder) } if fc == nil || fc.Domain != "" || !fc.HttpOnly || !fc.Secure || fc.SameSite != http.SameSiteLaxMode || fc.Path != "/" { t.Fatalf("the files cookie must be host-only, HttpOnly, Secure, Lax: %+v", fc) } if jwt := filesCookieIn(w, fileBrowserJWTCookie); jwt == nil || jwt.MaxAge >= 0 || jwt.Domain != "files.example.hu" { t.Fatalf("FileBrowser's own earlier token must be cleared for files.: %+v", jwt) } ok := filesAsk(s, "files.example.hu", folder, htmlAccept, fc) if ok.Code != http.StatusOK || ok.Header().Get(header) != infra.FileBrowserSSOUser { t.Fatalf("with the files cookie: %d, header %q — want 200 naming %s", ok.Code, ok.Header().Get(header), infra.FileBrowserSSOUser) } // No files cookie: FileBrowser's own sign-in (password) — 200 WITHOUT the header. none := filesAsk(s, "files.example.hu", folder, htmlAccept) if none.Code != http.StatusOK || none.Header().Get(header) != "" { t.Fatalf("without the files cookie: %d header %q — want 200 and no header", none.Code, none.Header().Get(header)) } } // COMPANION RED-PROOF (observed 2026-10-19): drop the `s.gate.used["fs:"+…]` check in takeFilesToken → this fails with // "a replayed pass set the files cookie again". Restored. func TestFilesSSO_R1100_ReplayedPassRefused(t *testing.T) { s := newFilesServer(t) pass := passFrom(t, filesOpen(s, "/", sessionCookie(s)), "files.example.hu") if w := filesAsk(s, "files.example.hu", pass, "application/json"); filesCookieIn(w, filesCookieName) == nil { t.Fatal("the first use must pass") } w := filesAsk(s, "files.example.hu", pass, "application/json") if c := filesCookieIn(w, filesCookieName); c != nil && c.Value != "" { t.Fatal("a replayed pass set the files cookie again") } if w.Code != http.StatusForbidden { t.Fatalf("a replayed pass: %d, want 403", w.Code) } } // COMPANION RED-PROOF (observed 2026-10-19): drop the `now.Unix() > t.Exp` check in takeFilesToken → this fails with // "a pass 61 s old set the files cookie". Restored. func TestFilesSSO_R1100_ExpiredPassRefused(t *testing.T) { s := newFilesServer(t) now := time.Now() s.gateClock = func() time.Time { return now } pass := passFrom(t, filesOpen(s, "/", sessionCookie(s)), "files.example.hu") now = now.Add(gateTokenLife + time.Second) w := filesAsk(s, "files.example.hu", pass, "application/json") if c := filesCookieIn(w, filesCookieName); c != nil && c.Value != "" { t.Fatal("a pass 61 s old set the files cookie") } if w.Code != http.StatusForbidden { t.Fatalf("an expired pass: %d, want 403", w.Code) } } // A pass minted for another host (another box's files. under the same key; a setup-gate pass for this host) is // refused, and the answer is refused outright for any host but files.. // // Two guards bind a pass to its host: takeFilesToken's `t.Host != host`, and the session key, which is derived for one // host (filesSessionKey). COMPANION RED-PROOF (observed 2026-10-19): with either one alone removed this still passes; // with both removed it fails with "a pass for files.other.hu passed on files.example.hu: 302". Drop the // X-Forwarded-Host check in ServeFilesAuth → it fails with "the answer named the household on felhom.example.hu: 200". // Restored. func TestFilesSSO_R1100_PassForAnotherHostRefused(t *testing.T) { s := newFilesServer(t) sess := sessionCookie(s) // Another domain on the same data dir = the same gate key, so only the host binding can refuse it. other := filesHarness(t, "other.hu", s.cfg.Paths.DataDir) other.sessions = s.sessions foreign := passFrom(t, filesOpen(other, "/", sess), "files.other.hu") if foreign == "" { t.Fatal("setup: no pass from the other host") } w := filesAsk(s, "files.example.hu", foreign, "application/json") if c := filesCookieIn(w, filesCookieName); (c != nil && c.Value != "") || w.Code != http.StatusForbidden { t.Fatalf("a pass for files.other.hu passed on files.example.hu: %d", w.Code) } gate := filesCallbackURI + "?" + url.Values{"t": {s.mintGateToken("files.example.hu", "/")}}.Encode() if w := filesAsk(s, "files.example.hu", gate, "application/json"); w.Code != http.StatusForbidden || filesCookieIn(w, filesCookieName) != nil { t.Fatalf("a setup-gate pass opened the file manager: %d", w.Code) } fc := signIn(t, s, sess, "/") for _, h := range []string{"felhom.example.hu", "app.example.hu", ""} { w := filesAsk(s, h, "/", htmlAccept, fc) if w.Header().Get(s.filesHeader()) != "" || w.Code != http.StatusForbidden { t.Fatalf("the answer named the household on %s: %d", h, w.Code) } } } // No dashboard session → no pass: the dashboard's sign-in, then back to /__files/open. // // COMPANION RED-PROOF (observed 2026-10-19): drop `!s.isValidSession(c.Value)` from ServeFilesOpen's check → this fails // with "a browser with no session got a pass" (the invalid-cookie case). Restored. func TestFilesSSO_R1100_NoSessionNoPass(t *testing.T) { s := newFilesServer(t) for _, cs := range [][]*http.Cookie{nil, {{Name: sessionCookieName, Value: "not-a-session"}}} { w := filesOpen(s, "/files/HDD/x", cs...) if passFrom(t, w, "files.example.hu") != "" { t.Fatal("a browser with no session got a pass") } loc := w.Header().Get("Location") if w.Code != http.StatusFound || !strings.HasPrefix(loc, "/login?next=") || !strings.Contains(loc, url.QueryEscape(filesOpenPath)) { t.Fatalf("no session: %d → %q, want the sign-in with next=/__files/open", w.Code, loc) } } // A `to` that is not a FileBrowser path never leaves files.. if got := safeFilesPath("https://evil.example/x"); got != "/" { t.Fatalf("safeFilesPath let %q through", got) } for _, bad := range []string{"//evil.example/files/x", "/files/\\evil", "/files/a\r\nSet-Cookie: x"} { if got := safeFilesPath(bad); got != "/" { t.Fatalf("safeFilesPath(%q) = %q", bad, got) } } } // Signed out on the dashboard → the files cookie stops at the next request: both cookies cleared, the browser sent to // the dashboard, which asks for the sign-in again. The same after the dashboard password changes (the gate key rotates). // // COMPANION RED-PROOF (observed 2026-10-19): make filesSessionAlive return true for any key → this fails with "after the // sign-out the files cookie still named the household". Restored. func TestFilesSSO_R1100_SignedOutAsksAgain(t *testing.T) { s := newFilesServer(t) sess := sessionCookie(s) fc := signIn(t, s, sess, "/") header := s.filesHeader() s.sessionsMu.Lock() delete(s.sessions, sessionFingerprint(sess.Value)) // what handleLogout does s.sessionsMu.Unlock() w := filesAsk(s, "files.example.hu", "/files/HDD/x", htmlAccept, fc) if w.Header().Get(header) != "" { t.Fatal("after the sign-out the files cookie still named the household") } if loc := w.Header().Get("Location"); w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://felhom.example.hu"+filesOpenPath) { t.Fatalf("after the sign-out: %d → %q, want 302 to the dashboard", w.Code, loc) } for _, n := range []string{filesCookieName, fileBrowserJWTCookie} { if c := filesCookieIn(w, n); c == nil || c.MaxAge >= 0 { t.Fatalf("%s was not cleared after the sign-out", n) } } if api := filesAsk(s, "files.example.hu", "/api/resources", "application/json", fc); api.Code != http.StatusUnauthorized || api.Header().Get(header) != "" { t.Fatalf("an API call with the stale cookie: %d", api.Code) } if passFrom(t, filesOpen(s, "/", sess), "files.example.hu") != "" { t.Fatal("the dashboard minted a pass for the signed-out session") } // The dashboard password changes → every gate pass and files cookie stops (rotateGateKey). sess2 := sessionCookie(s) fc2 := signIn(t, s, sess2, "/") s.rotateGateKey() if w := filesAsk(s, "files.example.hu", "/", htmlAccept, fc2); w.Header().Get(header) != "" { t.Fatal("a files cookie signed with the old gate key still named the household") } } // The header name is the box's secret: X-Felhom-User-<32 hex>, made once, 0600, the same on every read, different on // another box; a damaged file is an error (never a silent second name that splits the config from the route). func TestFileBrowserSSOHeader_R1100_ABoxSecret(t *testing.T) { d1, d2 := t.TempDir(), t.TempDir() h1, err := infra.LoadOrCreateFileBrowserSSOHeader(d1) if err != nil || !infra.ValidFileBrowserSSOHeader(h1) || h1 == "X-Felhom-User" { t.Fatalf("first read: %q %v", h1, err) } if again, _ := infra.LoadOrCreateFileBrowserSSOHeader(d1); again != h1 { t.Fatalf("a second read made another name: %q vs %q", again, h1) } if h2, _ := infra.LoadOrCreateFileBrowserSSOHeader(d2); h2 == h1 { t.Fatal("two boxes got the same header name") } st, err := os.Stat(filepath.Join(d1, "filebrowser-sso-header")) if err != nil || st.Mode().Perm() != 0o600 { t.Fatalf("the name file must be 0600: %v %v", st, err) } if err := os.WriteFile(filepath.Join(d2, "filebrowser-sso-header"), []byte("X-Felhom-User\n"), 0o600); err != nil { t.Fatal(err) } if _, err := infra.LoadOrCreateFileBrowserSSOHeader(d2); err == nil { t.Fatal("a damaged name file must be an error") } // Two writers at once (the stacks manager and the web server) get one name. d3 := t.TempDir() got := make(chan string, 8) for i := 0; i < 8; i++ { go func() { h, _ := infra.LoadOrCreateFileBrowserSSOHeader(d3); got <- h }() } first := <-got for i := 1; i < 8; i++ { if h := <-got; h != first || h == "" { t.Fatalf("parallel writers got different names: %q vs %q", h, first) } } } // The data tab's „FileBrowser asks for its own password the first time" note is said only on a box without the one // sign-in — with it the folder links open FileBrowser signed in, and the note would be false. // // COMPANION RED-PROOF (observed 2026-10-19): drop `{{if not .FilesSSO}}` from app_data.html → this fails with "the data // tab still says FileBrowser asks for its own password". Restored. func TestFilesSSO_R1100_DataTabLoginNote(t *testing.T) { s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA, lfDriveB) if err := os.MkdirAll(s.cfg.Paths.DataDir, 0o755); err != nil { t.Fatal(err) } if _, err := infra.LoadOrCreateFileBrowserSSOHeader(s.cfg.Paths.DataDir); err != nil { t.Fatal(err) } s.filesSSO.header = "" if _, data, _ := lfGet(t, s, "/apps/plex/data"); strings.Contains(data, `data-fb-login-note="1"`) { t.Fatal("the data tab still says FileBrowser asks for its own password") } s.filesSSO.header = "" s.cfg.Paths.DataDir = filepath.Join(t.TempDir(), "missing") // no name file can be made: no one sign-in if _, data, _ := lfGet(t, s, "/apps/plex/data"); !strings.Contains(data, `data-fb-login-note="1"`) { t.Fatal("without the one sign-in the note must stay") } }