Files
sm-user-plugin/session_test.go
2026-10-05 13:02:23 +02:00

584 lines
18 KiB
Go

package user
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/sm-user-plugin/updates"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/postcard"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/go-gormigrate/gormigrate/v2"
"github.com/golang-jwt/jwt/v5"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"gorm.io/gorm"
)
const (
sessionSecret = "summercms-test-only-hs256-secret"
// adminHarnessSecret signs backend tokens in tests that mount the admin API.
adminHarnessSecret = "summercms-test-only-admin-hs256-secret"
invalidLoginBody = `{"error":true,"message":"Invalid email or password"}`
prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"
)
var (
sessionOnce sync.Once
sessionGDB *gorm.DB
sessionSQL *sql.DB
// sessionDSN is the container's connection string; the admin harness
// opens its own database in the same container with it.
sessionDSN string
sessionErr error
)
func TestLogin(t *testing.T) {
app, gdb := sessionApp(t, "0s")
hash, err := bouncer.HashPassword(10, "correct-horse")
if err != nil {
t.Fatal(err)
}
user := &models.User{Email: "ada-login@example.test", Password: hash}
if err := gdb.Create(user).Error; err != nil {
t.Fatal(err)
}
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ada-login@example.test","password":"correct-horse"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
token, _ := body["token"].(string)
claims := parseToken(t, token)
if claims["sub"] != strconv.FormatUint(uint64(user.ID), 10) {
t.Fatalf("sub = %v", claims["sub"])
}
if claims["prv"] != prvHash {
t.Fatalf("prv = %v", claims["prv"])
}
if claims["iss"] != "http://example.com/_user/api/v1/login" {
t.Fatalf("iss = %v", claims["iss"])
}
userBody, _ := body["user"].(map[string]any)
if userBody["email"] != user.Email || userBody["feedback_widget_hidden"] != false {
t.Fatalf("user payload = %#v", userBody)
}
if userBody["permissions"] != nil {
t.Fatalf("permissions = %#v", userBody["permissions"])
}
wrong := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ada-login@example.test","password":"nope"}`)
unknown := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"nobody@example.test","password":"nope"}`)
if wrong.Code != http.StatusUnauthorized || wrong.Body.String() != invalidLoginBody {
t.Fatalf("wrong = %d %s", wrong.Code, wrong.Body.String())
}
if unknown.Body.String() != wrong.Body.String() {
t.Fatalf("unknown body %s", unknown.Body.String())
}
var before int64
if err := gdb.Model(&models.Throttle{}).Count(&before).Error; err != nil {
t.Fatal(err)
}
again := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"ghost@example.test","password":"nope"}`)
if again.Body.String() != invalidLoginBody {
t.Fatal(again.Body.String())
}
var after int64
if err := gdb.Model(&models.Throttle{}).Count(&after).Error; err != nil {
t.Fatal(err)
}
if after != before {
t.Fatalf("unknown email created a throttle row (%d -> %d)", before, after)
}
now := time.Now()
ip := "192.0.2.1"
throttled := &models.User{Email: "throttled@example.test", Password: hash}
if err := gdb.Create(throttled).Error; err != nil {
t.Fatal(err)
}
row := models.Throttle{UserID: throttled.ID, IPAddress: &ip, Attempts: 5, IsSuspended: true, SuspendedAt: &now}
if err := gdb.Create(&row).Error; err != nil {
t.Fatal(err)
}
blocked := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"throttled@example.test","password":"correct-horse"}`)
if blocked.Code != http.StatusUnauthorized || blocked.Body.String() != invalidLoginBody {
t.Fatalf("throttled = %d %s", blocked.Code, blocked.Body.String())
}
loc := "en"
soft := &models.User{Email: "soft@example.test", Password: hash, PreferredLocale: &loc}
if err := gdb.Create(soft).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Model(soft).Update("deleted_at", time.Now()).Error; err != nil {
t.Fatal(err)
}
mail := &captureMail{}
if err := app.Publish[postcard.Mailer](mail); err != nil {
t.Fatal(err)
}
restored := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"soft@example.test","password":"correct-horse"}`)
if restored.Code != http.StatusOK {
t.Fatalf("restore %d %s", restored.Code, restored.Body.String())
}
var againUser models.User
if err := gdb.Unscoped().First(&againUser, soft.ID).Error; err != nil {
t.Fatal(err)
}
if againUser.DeletedAt.Valid {
t.Fatal("soft-deleted user was not restored")
}
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
t.Fatalf("reactivate template = %#v", mail.got)
}
}
func TestLoginSixthAttempt(t *testing.T) {
app, gdb := sessionApp(t, "0s")
hash, err := bouncer.HashPassword(10, "correct-horse")
if err != nil {
t.Fatal(err)
}
user := &models.User{Email: "sixth@example.test", Password: hash}
if err := gdb.Create(user).Error; err != nil {
t.Fatal(err)
}
for i := 1; i <= 5; i++ {
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"sixth@example.test","password":"wrong"}`)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("attempt %d: %d %s", i, rec.Code, rec.Body.String())
}
}
var row models.Throttle
if err := gdb.Where("user_id = ?", user.ID).Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.Attempts != 5 || !row.IsSuspended {
t.Fatalf("after 5 logins: %+v", row)
}
blocked := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"sixth@example.test","password":"correct-horse"}`)
if blocked.Code != http.StatusUnauthorized || blocked.Body.String() != invalidLoginBody {
t.Fatalf("6th = %d %s", blocked.Code, blocked.Body.String())
}
if err := gdb.Where("user_id = ?", user.ID).Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.Attempts != 5 {
t.Fatalf("6th login recorded another attempt: %+v", row)
}
}
func TestLogout(t *testing.T) {
app, gdb := sessionApp(t, "0s")
token := loginToken(t, app, gdb, "logout@example.test")
rec := bearer(t, controllers.Logout(app), http.MethodPost, "/_user/api/v1/logout", token)
if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Logged out"}` {
t.Fatalf("logout %d %s", rec.Code, rec.Body.String())
}
fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token)
if fetch.Code != http.StatusUnauthorized || fetch.Body.String() != `{"error":true,"message":"Unauthorized"}` {
t.Fatalf("fetch after logout %d %s", fetch.Code, fetch.Body.String())
}
}
func TestFetch(t *testing.T) {
app, gdb := sessionApp(t, "0s")
token := loginToken(t, app, gdb, "fetch@example.test")
ok := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token)
if ok.Code != http.StatusOK || !strings.Contains(ok.Body.String(), `"email":"fetch@example.test"`) {
t.Fatalf("fetch %d %s", ok.Code, ok.Body.String())
}
missing := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", "")
if missing.Code != http.StatusUnauthorized || missing.Body.String() != `{"error":true,"message":"Unauthorized"}` {
t.Fatalf("missing %d %s", missing.Code, missing.Body.String())
}
bad := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", "not-a-token")
if bad.Body.String() != missing.Body.String() {
t.Fatalf("invalid %s", bad.Body.String())
}
}
func TestRefresh(t *testing.T) {
app, gdb := sessionApp(t, "0s")
user := insertUser(t, gdb, "refresh@example.test", "correct-horse")
sub := strconv.FormatUint(uint64(user.ID), 10)
missing := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", "")
if missing.Code != http.StatusUnauthorized || missing.Body.String() != `{"error":"Token not found"}` {
t.Fatalf("missing %d %s", missing.Code, missing.Body.String())
}
iat := time.Now().Add(-2 * time.Hour)
old := craftToken(t, sub, "refresh-old", iat, iat.Add(time.Minute))
rec := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", old)
if rec.Code != http.StatusOK {
t.Fatalf("refresh %d %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
next, _ := body["token"].(string)
if next == "" || next == old {
t.Fatalf("new token = %q", next)
}
if parseToken(t, next)["jti"] == "refresh-old" {
t.Fatal("refresh reused jti")
}
bl, ok := app.Lookup[bouncer.BlacklistStore]()
if !ok || bl == nil {
t.Fatal("blacklist was not published")
}
blocked, err := bl.IsBlacklisted(t.Context(), "refresh-old")
if err != nil || !blocked {
t.Fatalf("old jti blacklisted=%v err=%v", blocked, err)
}
fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", old)
if fetch.Code != http.StatusUnauthorized {
t.Fatalf("old token still fetches: %d %s", fetch.Code, fetch.Body.String())
}
stale := craftToken(t, sub, "refresh-stale", time.Now().Add(-40*24*time.Hour), time.Now().Add(-39*24*time.Hour))
denied := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", stale)
if denied.Code != http.StatusUnauthorized || !strings.Contains(denied.Body.String(), `"error":"Could not refresh token"`) || !strings.Contains(denied.Body.String(), `"msg":`) {
t.Fatalf("stale %d %s", denied.Code, denied.Body.String())
}
}
func TestLoginRouteGroup(t *testing.T) {
cfg := sessionConfig(t)
if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil {
t.Fatal(err)
}
if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
p := &Plugin{}
if err := p.Boot(app); err != nil {
t.Fatal(err)
}
rt, err := surf.BuildRouter(app, []party.Plugin{p})
if err != nil {
t.Fatal(err)
}
want := map[string]bool{
"POST /_user/api/v1/login": false,
"POST /_user/api/v1/logout": false,
"GET /_user/api/v1/fetch": false,
"POST /_user/api/v1/refresh": false,
"POST /_user/api/v1/register": false,
"GET /_user/api/v1/oauth-providers": false,
}
for _, info := range rt.Routes() {
key := info.Method + " " + info.Pattern
if _, ok := want[key]; !ok {
continue
}
want[key] = true
if len(info.Middleware) != 1 || info.Middleware[0] != "throttle:user-api" {
t.Fatalf("%s middleware = %#v", key, info.Middleware)
}
}
for key, seen := range want {
if !seen {
t.Fatalf("missing route %s", key)
}
}
}
func TestLoginBlacklistSweep(t *testing.T) {
gdb, sqlDB := sessionDB(t)
app := newSessionApp(t, gdb, sqlDB)
_, stop := bootPlugin(t, app, "50ms")
past := time.Now().Add(-time.Minute)
if err := gdb.Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, ?, ?)`, "sweep-me", past, past).Error; err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for {
var n int64
if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, "sweep-me").Scan(&n).Error; err != nil {
t.Fatal(err)
}
if n == 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("expired jwt_blacklist row was not swept")
}
time.Sleep(20 * time.Millisecond)
}
stop()
time.Sleep(150 * time.Millisecond)
quiet := newSessionApp(t, gdb, sqlDB)
bootPlugin(t, quiet, "0s")
later := time.Now().Add(-time.Minute)
if err := gdb.Exec(`INSERT INTO jwt_blacklist (jti, expires_at, valid_until) VALUES (?, ?, ?)`, "sweep-keep", later, later).Error; err != nil {
t.Fatal(err)
}
time.Sleep(200 * time.Millisecond)
var n int64
if err := gdb.Raw(`SELECT COUNT(*) FROM jwt_blacklist WHERE jti = ?`, "sweep-keep").Scan(&n).Error; err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatal("non-positive sweep interval removed a row")
}
}
type captureMail struct {
got []string
}
func (c *captureMail) Send(_ context.Context, msg postcard.Message) error {
c.got = append(c.got, msg.Template)
return nil
}
func loginToken(t *testing.T, app *backpack.App, gdb *gorm.DB, email string) string {
t.Helper()
insertUser(t, gdb, email, "correct-horse")
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"`+email+`","password":"correct-horse"}`)
if rec.Code != http.StatusOK {
t.Fatalf("login %s: %d %s", email, rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
token, _ := body["token"].(string)
if token == "" {
t.Fatal("empty token")
}
return token
}
func insertUser(t *testing.T, gdb *gorm.DB, email, password string) *models.User {
t.Helper()
hash, err := bouncer.HashPassword(10, password)
if err != nil {
t.Fatal(err)
}
user := &models.User{Email: email, Password: hash}
if err := gdb.Create(user).Error; err != nil {
t.Fatal(err)
}
return user
}
func postJSON(t *testing.T, h http.HandlerFunc, path, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Host = "example.com"
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
func bearer(t *testing.T, h http.HandlerFunc, method, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
req.Host = "example.com"
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
func parseToken(t *testing.T, raw string) jwt.MapClaims {
t.Helper()
claims := jwt.MapClaims{}
if _, err := jwt.ParseWithClaims(raw, claims, func(*jwt.Token) (any, error) {
return []byte(sessionSecret), nil
}); err != nil {
t.Fatal(err)
}
return claims
}
func craftToken(t *testing.T, sub, jti string, iat, exp time.Time) string {
t.Helper()
claims := jwt.MapClaims{
"sub": sub,
"iss": "http://example.com/_user/api/v1/login",
"iat": iat.Unix(),
"nbf": iat.Unix(),
"exp": exp.Unix(),
"jti": jti,
"prv": prvHash,
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret))
if err != nil {
t.Fatal(err)
}
return signed
}
func sessionApp(t *testing.T, sweep string) (*backpack.App, *gorm.DB) {
t.Helper()
gdb, sqlDB := sessionDB(t)
app := backpack.New(sessionConfig(t))
if err := app.Config.Set("http.body_limits.default_bytes", 1048576); err != nil {
t.Fatal(err)
}
if err := app.Config.Set("http.body_limits.upload_bytes", 1048576); err != nil {
t.Fatal(err)
}
if sweep != "" {
if err := app.Config.Set("golem15.user.jwt.blacklist_sweep_interval", sweep); err != nil {
t.Fatal(err)
}
}
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
bootPlugin(t, app, sweep)
return app, gdb
}
func newSessionApp(t *testing.T, gdb *gorm.DB, sqlDB *sql.DB) *backpack.App {
t.Helper()
app := backpack.New(sessionConfig(t))
if err := app.Config.Set("http.body_limits.default_bytes", 1048576); err != nil {
t.Fatal(err)
}
if err := app.Config.Set("http.body_limits.upload_bytes", 1048576); err != nil {
t.Fatal(err)
}
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
return app
}
func bootPlugin(t *testing.T, app *backpack.App, sweep string) (*Plugin, func()) {
t.Helper()
if sweep != "" {
if err := app.Config.Set("golem15.user.jwt.blacklist_sweep_interval", sweep); err != nil {
t.Fatal(err)
}
}
p := &Plugin{}
if err := p.Boot(app); err != nil {
t.Fatal(err)
}
stop := func() {}
if p.sweepStop != nil {
ch := p.sweepStop
var once sync.Once
stop = func() { once.Do(func() { close(ch) }) }
t.Cleanup(stop)
}
return p, stop
}
func sessionConfig(t *testing.T) *compass.Config {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-session\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{
"SUMMER_ENV=development",
"SUMMER_GOLEM15__USER__JWT__SECRET=" + sessionSecret,
// The plugin registers admin controllers, so assembling its
// routes mounts the admin API, which needs its own secret.
"SUMMER_ADMIN__JWT__SECRET=" + adminHarnessSecret,
},
})
if err != nil {
t.Fatal(err)
}
return cfg
}
func sessionDB(t *testing.T) (*gorm.DB, *sql.DB) {
t.Helper()
sessionOnce.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("user_session"),
postgres.WithUsername("user_session"),
postgres.WithPassword("user_session"),
postgres.BasicWaitStrategies(),
testcontainers.WithEnv(map[string]string{
"POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8",
}),
)
if err != nil {
sessionErr = err
return
}
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
sessionErr = err
return
}
sessionDSN = dsn
sqlDB, err := sql.Open("pgx", dsn)
if err != nil {
sessionErr = err
return
}
gdb, err := lagoon.Use(ctx, sqlDB)
if err != nil {
sessionErr = err
return
}
m := gormigrate.New(gdb, &gormigrate.Options{
TableName: "summer_migrations_golem15_user",
UseTransaction: true,
}, updates.All())
if err := m.Migrate(); err != nil {
sessionErr = err
return
}
files := gormigrate.New(gdb, &gormigrate.Options{
TableName: "summer_migrations_system_files",
UseTransaction: true,
}, attach.Migrations)
if err := files.Migrate(); err != nil {
sessionErr = err
return
}
sessionGDB = gdb
sessionSQL = sqlDB
})
if sessionErr != nil {
t.Fatal(sessionErr)
}
return sessionGDB, sessionSQL
}