From 40e1cfb630f72032f50be0bc05887f0f445172be Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sat, 22 Aug 2026 23:42:52 -0500 Subject: [PATCH] Add web-layer route/auth tests --- internal/web/router_test.go | 266 ++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 internal/web/router_test.go diff --git a/internal/web/router_test.go b/internal/web/router_test.go new file mode 100644 index 0000000..42bce56 --- /dev/null +++ b/internal/web/router_test.go @@ -0,0 +1,266 @@ +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} + + 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(), ""), + 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") + } +}