Files
felhom-controller/controller/internal/web/files_sso_test.go
T

343 lines
15 KiB
Go

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.<domain>. 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.<domain>: %+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.<domain> under the same key; a setup-gate pass for this host) is
// refused, and the answer is refused outright for any host but files.<domain>.
//
// 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.<domain>.
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")
}
}