Vellum/internal/auth/auth_test.go
mithral 9772e55c73 Phase 1: accounts, sessions, per-library grants
Multi-user from the first migration, as planned.

- auth: argon2id password hashing (PHC encoded, parameters travel with the
  hash), opaque session tokens stored only as SHA-256, login rate limiting,
  and account enumeration resistance (unknown user hashes anyway)
- sessions: cookie or Bearer auth, HttpOnly + SameSite=Lax, revocation on
  password change
- grants: library_grants is the single access-control chokepoint. Admins see
  every library; everyone else sees exactly their can_read grants
- bootstrap: VELLUM_ADMIN_USER/PASSWORD create the first admin only on an
  empty database. No default password is ever invented.
- httpapi: mounts modules on a shared mux, session middleware innermost
- httpx: shared JSON helpers, strict body decoding

Gate: two users see different libraries. Tests cover that, plus session
expiry, token-at-rest, grant upsert, and the full login/me/logout flow.
2026-10-09 16:31:24 -04:00

454 lines
13 KiB
Go

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) }