package web import ( "database/sql" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "git.noroutes.net/NoRoutes/vibetunes/internal/auth" "git.noroutes.net/NoRoutes/vibetunes/internal/config" "git.noroutes.net/NoRoutes/vibetunes/internal/coverart" "git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/metadata" "git.noroutes.net/NoRoutes/vibetunes/internal/scanner" ) func newTestWeb(t *testing.T) (*Web, *sql.DB) { t.Helper() db, err := database.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("database.Open: %v", err) } t.Cleanup(func() { db.Close() }) log := slog.New(slog.NewTextHandler(io.Discard, nil)) sessions := &auth.Sessions{DB: db, CookieSecure: false} extauth := &auth.ExtAuth{DB: db, Config: config.ExtAuthConfig{}} mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth} key, err := auth.EncryptionKey(db, "test-secret") if err != nil { t.Fatalf("EncryptionKey: %v", err) } h := &Web{ DB: db, Config: config.Config{}, Sessions: sessions, CSRF: &auth.CSRF{CookieSecure: false}, Middleware: mw, Scanner: scanner.New(db, &metadata.Prober{}, log), CoverArt: coverart.New(t.TempDir(), ""), EncryptionKey: key, Log: log, } return h, db } func doRequest(t *testing.T, srv *httptest.Server, jar map[string]*http.Cookie, method, path string, form url.Values) *http.Response { t.Helper() var body io.Reader if form != nil { body = strings.NewReader(form.Encode()) } req, err := http.NewRequest(method, srv.URL+path, body) if err != nil { t.Fatal(err) } if form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } for _, c := range jar { req.AddCookie(c) } client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { t.Fatal(err) } for _, c := range resp.Cookies() { jar[c.Name] = c } return resp } func TestSetupGateRedirectsWithNoUsers(t *testing.T) { h, _ := newTestWeb(t) srv := httptest.NewServer(h.NewRouter()) defer srv.Close() jar := map[string]*http.Cookie{} resp := doRequest(t, srv, jar, http.MethodGet, "/", nil) if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/setup" { t.Fatalf("GET / with no users = %d %s, want 302 to /setup", resp.StatusCode, resp.Header.Get("Location")) } } func TestHealthzAlwaysAccessible(t *testing.T) { h, _ := newTestWeb(t) srv := httptest.NewServer(h.NewRouter()) defer srv.Close() jar := map[string]*http.Cookie{} resp := doRequest(t, srv, jar, http.MethodGet, "/healthz", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("GET /healthz = %d, want 200", resp.StatusCode) } resp2 := doRequest(t, srv, jar, http.MethodGet, "/readyz", nil) if resp2.StatusCode != http.StatusOK { t.Fatalf("GET /readyz = %d, want 200", resp2.StatusCode) } } func csrfToken(t *testing.T, srv *httptest.Server, jar map[string]*http.Cookie, path string) string { t.Helper() resp := doRequest(t, srv, jar, http.MethodGet, path, nil) body, _ := io.ReadAll(resp.Body) resp.Body.Close() i := strings.Index(string(body), `name="csrf_token" value="`) if i < 0 { t.Fatalf("no csrf token found on %s", path) } rest := string(body)[i+len(`name="csrf_token" value="`):] return rest[:strings.Index(rest, `"`)] } func doSetup(t *testing.T, srv *httptest.Server, jar map[string]*http.Cookie, folderPath string) { t.Helper() token := csrfToken(t, srv, jar, "/setup") resp := doRequest(t, srv, jar, http.MethodPost, "/setup", url.Values{ "csrf_token": {token}, "username": {"admin"}, "password": {"adminpassword123"}, "folder_name": {"Music"}, "folder_path": {folderPath}, }) if resp.StatusCode != http.StatusFound { body, _ := io.ReadAll(resp.Body) t.Fatalf("POST /setup = %d, want 302; body: %s", resp.StatusCode, body) } } func TestSetupCreatesAdminAndLogsIn(t *testing.T) { h, db := newTestWeb(t) srv := httptest.NewServer(h.NewRouter()) defer srv.Close() jar := map[string]*http.Cookie{} doSetup(t, srv, jar, t.TempDir()) n, err := database.CountUsers(db) if err != nil { t.Fatalf("CountUsers: %v", err) } if n != 1 { t.Fatalf("CountUsers = %d, want 1", n) } resp := doRequest(t, srv, jar, http.MethodGet, "/", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("GET / after setup = %d, want 200 (should be signed in already)", resp.StatusCode) } } func TestUnauthenticatedRedirectsToLogin(t *testing.T) { h, _ := newTestWeb(t) srv := httptest.NewServer(h.NewRouter()) defer srv.Close() jar := map[string]*http.Cookie{} doSetup(t, srv, jar, t.TempDir()) // A fresh client with no session cookie. freshJar := map[string]*http.Cookie{} resp := doRequest(t, srv, freshJar, http.MethodGet, "/albums", nil) if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") == "" || !strings.HasPrefix(resp.Header.Get("Location"), "/login") { t.Fatalf("GET /albums unauthenticated = %d %s, want 302 to /login", resp.StatusCode, resp.Header.Get("Location")) } } func TestNonAdminCannotAccessAdminRoutes(t *testing.T) { h, db := newTestWeb(t) srv := httptest.NewServer(h.NewRouter()) defer srv.Close() adminJar := map[string]*http.Cookie{} doSetup(t, srv, adminJar, t.TempDir()) // Create a normal (non-admin) user directly and log in as them. user, err := database.CreateUser(db, "regular", "", "", false) if err != nil { t.Fatalf("CreateUser: %v", err) } hash, err := auth.HashPassword("regularpassword123") if err != nil { t.Fatalf("HashPassword: %v", err) } if err := database.SetUserPassword(db, user.ID, hash); err != nil { t.Fatalf("SetUserPassword: %v", err) } userJar := map[string]*http.Cookie{} token := csrfToken(t, srv, userJar, "/login") resp := doRequest(t, srv, userJar, http.MethodPost, "/login", url.Values{ "csrf_token": {token}, "username": {"regular"}, "password": {"regularpassword123"}, }) if resp.StatusCode != http.StatusFound { t.Fatalf("login as regular user = %d, want 302", resp.StatusCode) } adminResp := doRequest(t, srv, userJar, http.MethodGet, "/admin/users", nil) if adminResp.StatusCode != http.StatusForbidden { t.Fatalf("GET /admin/users as non-admin = %d, want 403", adminResp.StatusCode) } // The actual admin can. adminOK := doRequest(t, srv, adminJar, http.MethodGet, "/admin/users", nil) if adminOK.StatusCode != http.StatusOK { t.Fatalf("GET /admin/users as admin = %d, want 200", adminOK.StatusCode) } } func TestCSRFRejectsMissingToken(t *testing.T) { h, _ := newTestWeb(t) srv := httptest.NewServer(h.NewRouter()) defer srv.Close() jar := map[string]*http.Cookie{} doSetup(t, srv, jar, t.TempDir()) resp := doRequest(t, srv, jar, http.MethodPost, "/playlists", url.Values{"name": {"My Playlist"}}) if resp.StatusCode != http.StatusForbidden { t.Fatalf("POST without csrf_token = %d, want 403", resp.StatusCode) } } func TestStreamRequiresAuth(t *testing.T) { h, _ := newTestWeb(t) srv := httptest.NewServer(h.NewRouter()) defer srv.Close() adminJar := map[string]*http.Cookie{} doSetup(t, srv, adminJar, t.TempDir()) jar := map[string]*http.Cookie{} resp := doRequest(t, srv, jar, http.MethodGet, "/stream/sg_doesnotexist", nil) if resp.StatusCode != http.StatusFound || !strings.HasPrefix(resp.Header.Get("Location"), "/login") { t.Fatalf("GET /stream unauthenticated = %d %s, want redirect to /login", resp.StatusCode, resp.Header.Get("Location")) } } func TestLoginRejectsWrongPassword(t *testing.T) { h, _ := newTestWeb(t) srv := httptest.NewServer(h.NewRouter()) defer srv.Close() jar := map[string]*http.Cookie{} doSetup(t, srv, jar, t.TempDir()) h.Sessions.Clear(httptest.NewRecorder(), &http.Request{Header: http.Header{}}) freshJar := map[string]*http.Cookie{} token := csrfToken(t, srv, freshJar, "/login") resp := doRequest(t, srv, freshJar, http.MethodPost, "/login", url.Values{ "csrf_token": {token}, "username": {"admin"}, "password": {"wrong-password"}, }) if resp.StatusCode != http.StatusOK { t.Fatalf("login with wrong password = %d, want 200 (re-rendered form)", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "Invalid username or password") { t.Error("expected login failure message in response body") } } func TestAdminPermissionsCannotDemoteLastAdmin(t *testing.T) { h, db := newTestWeb(t) srv := httptest.NewServer(h.NewRouter()) defer srv.Close() adminJar := map[string]*http.Cookie{} doSetup(t, srv, adminJar, t.TempDir()) admin, err := database.GetUserByUsername(db, "admin") if err != nil { t.Fatalf("GetUserByUsername: %v", err) } token := csrfToken(t, srv, adminJar, "/admin/users/"+admin.ID+"/permissions") resp := doRequest(t, srv, adminJar, http.MethodPost, "/admin/users/"+admin.ID+"/permissions", url.Values{ "csrf_token": {token}, // "admin" intentionally omitted: attempting to demote the only admin. "stream": {"1"}, }) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("demote last admin = %d, want 400", resp.StatusCode) } got, err := database.GetUserByID(db, admin.ID) if err != nil { t.Fatalf("GetUserByID: %v", err) } if !got.IsAdmin { t.Fatal("last admin was demoted despite the guard") } } func TestAdminPermissionsSetsMusicFolderRestriction(t *testing.T) { h, db := newTestWeb(t) srv := httptest.NewServer(h.NewRouter()) defer srv.Close() adminJar := map[string]*http.Cookie{} doSetup(t, srv, adminJar, t.TempDir()) user, err := database.CreateUser(db, "regular", "", "", false) if err != nil { t.Fatalf("CreateUser: %v", err) } folder, err := database.CreateMusicFolder(db, "Music", t.TempDir(), "music") if err != nil { t.Fatalf("CreateMusicFolder: %v", err) } token := csrfToken(t, srv, adminJar, "/admin/users/"+user.ID+"/permissions") resp := doRequest(t, srv, adminJar, http.MethodPost, "/admin/users/"+user.ID+"/permissions", url.Values{ "csrf_token": {token}, "music_folders": {folder.ID}, "stream": {"1"}, }) if resp.StatusCode != http.StatusFound { t.Fatalf("save permissions = %d, want 302", resp.StatusCode) } allowed, err := database.AllowedMusicFolderIDs(db, user.ID) if err != nil { t.Fatalf("AllowedMusicFolderIDs: %v", err) } if len(allowed) != 1 || allowed[0] != folder.ID { t.Fatalf("AllowedMusicFolderIDs = %v, want [%s]", allowed, folder.ID) } }