package api import ( "bytes" "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL. // Skips in -short mode or when the env var is missing, matching the pattern // used elsewhere (scanner_test.go, etc.). func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { t.Helper() if testing.Short() { t.Skip("skipping api integration in -short mode") } dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") if dsn == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) if err := db.Migrate(dsn, logger); err != nil { t.Fatalf("migrate: %v", err) } pool, err := pgxpool.New(context.Background(), dsn) if err != nil { t.Fatalf("pool: %v", err) } t.Cleanup(pool.Close) if _, err := pool.Exec(context.Background(), "TRUNCATE sessions, users RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate: %v", err) } return &handlers{pool: pool, logger: logger}, pool } func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User { t.Helper() hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) if err != nil { t.Fatalf("bcrypt: %v", err) } u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ Username: username, PasswordHash: string(hash), ApiToken: "test-api-token-" + username, IsAdmin: isAdmin, }) if err != nil { t.Fatalf("CreateUser: %v", err) } return u } func TestHandleLogin_SuccessSetsCookieAndReturnsToken(t *testing.T) { h, pool := testHandlers(t) seedUser(t, pool, "alice", "hunter2", false) body := strings.NewReader(`{"username":"alice","password":"hunter2"}`) req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() h.handleLogin(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) } var resp struct { Token string `json:"token"` User struct { Username string `json:"username"` IsAdmin bool `json:"is_admin"` } `json:"user"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v\nbody=%s", err, w.Body.String()) } if resp.Token == "" { t.Error("token empty") } if resp.User.Username != "alice" || resp.User.IsAdmin { t.Errorf("user = %+v, want alice/non-admin", resp.User) } var cookieFound bool for _, c := range w.Result().Cookies() { if c.Name == auth.SessionCookieName { cookieFound = true if !c.HttpOnly { t.Error("session cookie missing HttpOnly") } if c.SameSite != http.SameSiteStrictMode { t.Errorf("SameSite = %v, want Strict", c.SameSite) } if c.Value != resp.Token { t.Error("cookie value does not match response token") } } } if !cookieFound { t.Error("session cookie not set") } } func TestHandleLogin_WrongPasswordReturns401(t *testing.T) { h, pool := testHandlers(t) seedUser(t, pool, "alice", "hunter2", false) body := strings.NewReader(`{"username":"alice","password":"wrong"}`) req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() h.handleLogin(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", w.Code) } } func TestHandleLogin_UnknownUserReturns401(t *testing.T) { h, _ := testHandlers(t) body := strings.NewReader(`{"username":"ghost","password":"whatever"}`) req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() h.handleLogin(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", w.Code) } } func TestHandleLogin_MalformedBodyReturns400(t *testing.T) { h, _ := testHandlers(t) req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader([]byte("not-json"))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() h.handleLogin(w, req) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", w.Code) } }