feat(api): POST /api/auth/login
Verifies bcrypt password hash, mints a session token, stores its sha256 in the sessions table, and returns the token in both the response body (for Flutter) and an httpOnly/SameSite=Strict cookie (for the web SPA). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user