package auth import ( "context" "database/sql" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strconv" "strings" "testing" "time" "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/migrations" ) func testDB(t *testing.T) *sql.DB { t.Helper() db, err := core.OpenDB(filepath.Join(t.TempDir(), "auth.db")) if err != nil { t.Fatalf("OpenDB: %v", err) } t.Cleanup(func() { _ = db.Close() }) log := slog.New(slog.NewTextHandler(io.Discard, nil)) if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil { t.Fatalf("Migrate: %v", err) } return db } func mustCreateUser(t *testing.T, db *sql.DB, username string, admin bool) User { t.Helper() u, err := CreateUser(context.Background(), db, username, "", "password123", admin) if err != nil { t.Fatalf("CreateUser(%s): %v", username, err) } return u } func mustCreateLibrary(t *testing.T, db *sql.DB, name, kind, root string) int64 { t.Helper() var id int64 err := db.QueryRowContext(context.Background(), `INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`, name, kind, root).Scan(&id) if err != nil { t.Fatalf("insert library: %v", err) } return id } // ── users ────────────────────────────────────────────────────────────── func TestCreateUserAndAuthenticate(t *testing.T) { db := testDB(t) ctx := context.Background() u, err := CreateUser(ctx, db, "alice", "Alice", "hunter2hunter2", false) if err != nil { t.Fatalf("CreateUser: %v", err) } if u.DisplayName != "Alice" { t.Errorf("DisplayName = %q", u.DisplayName) } got, err := Authenticate(ctx, db, "alice", "hunter2hunter2") if err != nil { t.Fatalf("Authenticate: %v", err) } if got.ID != u.ID { t.Errorf("authenticated id = %d, want %d", got.ID, u.ID) } // Usernames are case-insensitive. if _, err := Authenticate(ctx, db, "ALICE", "hunter2hunter2"); err != nil { t.Errorf("case-insensitive login failed: %v", err) } } func TestAuthenticateRejectsBadCredentials(t *testing.T) { db := testDB(t) ctx := context.Background() mustCreateUser(t, db, "bob", false) for _, tc := range []struct{ user, pass string }{ {"bob", "wrong"}, {"nobody", "password123"}, } { _, err := Authenticate(ctx, db, tc.user, tc.pass) if !errors.Is(err, ErrInvalidCredentials) { t.Errorf("Authenticate(%q,%q) error = %v, want ErrInvalidCredentials", tc.user, tc.pass, err) } } } func TestCreateUserRejectsDuplicateAndBadUsername(t *testing.T) { db := testDB(t) ctx := context.Background() mustCreateUser(t, db, "carol", false) if _, err := CreateUser(ctx, db, "CAROL", "", "password123", false); !errors.Is(err, ErrUserExists) { t.Errorf("duplicate error = %v, want ErrUserExists", err) } if _, err := CreateUser(ctx, db, "x", "", "password123", false); !errors.Is(err, ErrInvalidUsername) { t.Errorf("short username error = %v, want ErrInvalidUsername", err) } if _, err := CreateUser(ctx, db, "has space", "", "password123", false); !errors.Is(err, ErrInvalidUsername) { t.Errorf("username with space error = %v, want ErrInvalidUsername", err) } } func TestSetPasswordRevokesSessions(t *testing.T) { db := testDB(t) ctx := context.Background() u := mustCreateUser(t, db, "dave", false) token, err := CreateSession(ctx, db, u.ID, time.Hour, "test", "127.0.0.1", "test") if err != nil { t.Fatalf("CreateSession: %v", err) } if _, err := SessionByToken(ctx, db, token); err != nil { t.Fatalf("session should exist: %v", err) } if err := SetPassword(ctx, db, u.ID, "brandnewpass"); err != nil { t.Fatalf("SetPassword: %v", err) } if _, err := SessionByToken(ctx, db, token); !errors.Is(err, ErrNoSession) { t.Errorf("session survived password change: %v", err) } if _, err := Authenticate(ctx, db, "dave", "brandnewpass"); err != nil { t.Errorf("new password does not work: %v", err) } } // ── sessions ─────────────────────────────────────────────────────────── func TestSessionExpiry(t *testing.T) { db := testDB(t) ctx := context.Background() u := mustCreateUser(t, db, "erin", false) token, err := CreateSession(ctx, db, u.ID, -time.Minute, "test", "", "") if err != nil { t.Fatalf("CreateSession: %v", err) } if _, err := SessionByToken(ctx, db, token); !errors.Is(err, ErrNoSession) { t.Errorf("expired session resolved: %v", err) } } func TestDeleteExpiredSessions(t *testing.T) { db := testDB(t) ctx := context.Background() u := mustCreateUser(t, db, "frank", false) if _, err := CreateSession(ctx, db, u.ID, -time.Hour, "", "", ""); err != nil { t.Fatal(err) } if _, err := CreateSession(ctx, db, u.ID, time.Hour, "", "", ""); err != nil { t.Fatal(err) } n, err := DeleteExpiredSessions(ctx, db) if err != nil { t.Fatalf("DeleteExpiredSessions: %v", err) } if n != 1 { t.Errorf("deleted %d sessions, want 1", n) } } func TestRawTokenIsNotStored(t *testing.T) { db := testDB(t) ctx := context.Background() u := mustCreateUser(t, db, "grace", false) token, err := CreateSession(ctx, db, u.ID, time.Hour, "", "", "") if err != nil { t.Fatal(err) } var count int if err := db.QueryRowContext(ctx, `SELECT count(*) FROM sessions WHERE token_hash = ?`, token).Scan(&count); err != nil { t.Fatal(err) } if count != 0 { t.Error("raw session token found in the database") } } // ── grants / visibility ──────────────────────────────────────────────── func TestLibraryVisibility(t *testing.T) { db := testDB(t) ctx := context.Background() comics := mustCreateLibrary(t, db, "Comics", "comics", "/media/comics") ebooks := mustCreateLibrary(t, db, "Ebooks", "ebooks", "/media/ebooks") kid := mustCreateUser(t, db, "kid", false) admin := mustCreateUser(t, db, "admin", true) if err := GrantLibrary(ctx, db, kid.ID, comics, true, false); err != nil { t.Fatalf("GrantLibrary: %v", err) } kidLibs, err := VisibleLibraryIDs(ctx, db, kid) if err != nil { t.Fatal(err) } if len(kidLibs) != 1 || kidLibs[0] != comics { t.Errorf("kid visible libraries = %v, want [%d]", kidLibs, comics) } if ok, err := CanAccessLibrary(ctx, db, kid, comics); err != nil || !ok { t.Errorf("kid should access comics: ok=%v err=%v", ok, err) } if ok, err := CanAccessLibrary(ctx, db, kid, ebooks); err != nil || ok { t.Errorf("kid should NOT access ebooks: ok=%v err=%v", ok, err) } adminLibs, err := VisibleLibraryIDs(ctx, db, admin) if err != nil { t.Fatal(err) } if len(adminLibs) != 2 { t.Errorf("admin visible libraries = %v, want both", adminLibs) } // Revoking removes access. if err := RevokeLibrary(ctx, db, kid.ID, comics); err != nil { t.Fatal(err) } if ok, _ := CanAccessLibrary(ctx, db, kid, comics); ok { t.Error("kid still has access after revocation") } } func TestGrantLibraryUpserts(t *testing.T) { db := testDB(t) ctx := context.Background() u := mustCreateUser(t, db, "henry", false) lib := mustCreateLibrary(t, db, "Music", "music", "/media/music") if err := GrantLibrary(ctx, db, u.ID, lib, true, false); err != nil { t.Fatal(err) } if err := GrantLibrary(ctx, db, u.ID, lib, false, true); err != nil { t.Fatal(err) } grants, err := LibraryGrants(ctx, db, u.ID) if err != nil { t.Fatal(err) } if len(grants) != 1 { t.Fatalf("grants = %v, want exactly 1", grants) } if grants[0].CanRead || !grants[0].CanDownload { t.Errorf("grant = %+v, want read=false download=true", grants[0]) } } // ── HTTP flow ────────────────────────────────────────────────────────── func newAuthServer(t *testing.T, db *sql.DB) http.Handler { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) a := NewAuthenticator(db, "vellum_session", time.Hour, false, log) mux := http.NewServeMux() NewHandlers(a, log).Register(mux) return a.Middleware(mux) } func postJSON(t *testing.T, h http.Handler, path, body string, cookies []*http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func TestHTTPLoginMeLogout(t *testing.T) { db := testDB(t) h := newAuthServer(t, db) mustCreateUser(t, db, "irene", false) // Unauthenticated /me. req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("unauthenticated /me status = %d, want 401", rec.Code) } // Wrong password. rec = postJSON(t, h, "/api/v1/auth/login", `{"username":"irene","password":"nope"}`, nil) if rec.Code != http.StatusUnauthorized { t.Fatalf("bad login status = %d, want 401", rec.Code) } // Correct password. rec = postJSON(t, h, "/api/v1/auth/login", `{"username":"irene","password":"password123"}`, nil) if rec.Code != http.StatusOK { t.Fatalf("login status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } cookies := rec.Result().Cookies() if len(cookies) == 0 { t.Fatal("login did not set a cookie") } var sessCookie *http.Cookie for _, c := range cookies { if c.Name == "vellum_session" { sessCookie = c } } if sessCookie == nil { t.Fatal("no vellum_session cookie set") } if !sessCookie.HttpOnly { t.Error("session cookie is not HttpOnly") } // Authenticated /me. req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req.AddCookie(sessCookie) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("/me status = %d, want 200", rec.Code) } var me struct { User struct { Username string `json:"username"` } `json:"user"` VisibleLibraries []int64 `json:"visible_libraries"` } if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil { t.Fatalf("decode /me: %v", err) } if me.User.Username != "irene" { t.Errorf("/me username = %q", me.User.Username) } // Logout clears the session server-side. rec = postJSON(t, h, "/api/v1/auth/logout", "", []*http.Cookie{sessCookie}) if rec.Code != http.StatusOK { t.Fatalf("logout status = %d", rec.Code) } req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req.AddCookie(sessCookie) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("session still valid after logout: %d", rec.Code) } } func TestBearerTokenAlsoWorks(t *testing.T) { db := testDB(t) h := newAuthServer(t, db) u := mustCreateUser(t, db, "jill", false) token, err := CreateSession(context.Background(), db, u.ID, time.Hour, "", "", "") if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("bearer /me status = %d, want 200", rec.Code) } } func TestAdminRoutesRequireAdmin(t *testing.T) { db := testDB(t) h := newAuthServer(t, db) user := mustCreateUser(t, db, "mallory", false) token, err := CreateSession(context.Background(), db, user.ID, time.Hour, "", "", "") if err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Errorf("non-admin /users status = %d, want 403", rec.Code) } } func TestAdminCreatesUserAndGrants(t *testing.T) { db := testDB(t) h := newAuthServer(t, db) admin := mustCreateUser(t, db, "root", true) lib := mustCreateLibrary(t, db, "Games", "games", "/media/games") token, err := CreateSession(context.Background(), db, admin.ID, time.Hour, "", "", "") if err != nil { t.Fatal(err) } authHeader := "Bearer " + token // Create a user. req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"username":"newbie","password":"password123"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", authHeader) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("create user status = %d, body=%s", rec.Code, rec.Body.String()) } // Grant a library to user 2 (newbie is the second user). req = httptest.NewRequest(http.MethodPut, "/api/v1/users/2/grants/"+itoa(lib), strings.NewReader(`{"can_read":true,"can_download":true}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", authHeader) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("grant status = %d, body=%s", rec.Code, rec.Body.String()) } grants, err := LibraryGrants(context.Background(), db, 2) if err != nil { t.Fatal(err) } if len(grants) != 1 || !grants[0].CanRead || !grants[0].CanDownload { t.Errorf("grants = %+v", grants) } } func itoa(n int64) string { return strconv.FormatInt(n, 10) }