- TestRequestUser: bearer, token and auth_token cookies in guard order; expired, foreign, blacklisted, revoked by tokens_valid_after and deactivated tokens are anonymous; only infrastructure failures error - TestEndRequestSession (no database): jti blacklisted until the token expires, forget cookie with the configured Domain and Path, best effort - TestAvatarURL: thumbnail, broken-image and storage-failure fallbacks, gravatar with the size, the user payload's avatar_url unchanged - TestDeactivatingEvent: one event per newly deactivated user inside the bulk action's transaction, a listener error rolls the action back
298 lines
12 KiB
Go
298 lines
12 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// failingBlacklist fails every call, as an unreachable blacklist table does.
|
|
type failingBlacklist struct{}
|
|
|
|
var errBlacklistDown = errors.New("blacklist down")
|
|
|
|
func (failingBlacklist) Add(context.Context, string, time.Time, time.Time) error {
|
|
return errBlacklistDown
|
|
}
|
|
func (failingBlacklist) IsBlacklisted(context.Context, string) (bool, error) {
|
|
return false, errBlacklistDown
|
|
}
|
|
func (failingBlacklist) Sweep(context.Context, time.Time) error { return errBlacklistDown }
|
|
|
|
// sessionUserApp is an application without a database: the plugin's JWT secret
|
|
// and, when cookie is set, the 19.3 cookie settings.
|
|
func sessionUserApp(t *testing.T, environ ...string) *backpack.App {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: session-user\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return backpack.New(cfg)
|
|
}
|
|
|
|
// sessionRequest is a GET carrying the session token the way kind says:
|
|
// "bearer", "token" (cookie) or "auth_token" (cookie).
|
|
func sessionRequest(kind, token string) *http.Request {
|
|
r := httptest.NewRequest(http.MethodGet, "/oauth/authorize", nil)
|
|
switch kind {
|
|
case "bearer":
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
case "token", "auth_token":
|
|
r.AddCookie(&http.Cookie{Name: kind, Value: token})
|
|
}
|
|
return r
|
|
}
|
|
|
|
// TestRequestUser pins classes.RequestUser (D-05): the session JWT from the
|
|
// bearer header, then the token and auth_token cookies, checked like the
|
|
// plugin's jwt guard; every rejected token is anonymous and only an
|
|
// infrastructure failure is an error.
|
|
func TestRequestUser(t *testing.T) {
|
|
env := newAdminEnv(t)
|
|
ctx := t.Context()
|
|
issue := func(u *models.User) string {
|
|
t.Helper()
|
|
token, err := controllers.IssueToken(env.app, u, httptest.NewRequest(http.MethodPost, "https://app.example.test/login", nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return token
|
|
}
|
|
expectUser := func(name string, r *http.Request, want *models.User) {
|
|
t.Helper()
|
|
got, err := classes.RequestUser(ctx, env.app, r)
|
|
if err != nil {
|
|
t.Fatalf("%s: error %v", name, err)
|
|
}
|
|
switch {
|
|
case want == nil && got != nil:
|
|
t.Fatalf("%s: signed in user %d, want anonymous", name, got.ID)
|
|
case want != nil && (got == nil || got.ID != want.ID || got.Email != want.Email):
|
|
t.Fatalf("%s: user %+v, want %d", name, got, want.ID)
|
|
}
|
|
}
|
|
|
|
ada, bea := env.seedUser(t, "ada", nil), env.seedUser(t, "bea", nil)
|
|
adaToken, beaToken := issue(ada), issue(bea)
|
|
|
|
expectUser("bearer", sessionRequest("bearer", adaToken), ada)
|
|
expectUser("token cookie", sessionRequest("token", adaToken), ada)
|
|
expectUser("auth_token cookie", sessionRequest("auth_token", adaToken), ada)
|
|
both := sessionRequest("bearer", adaToken)
|
|
both.AddCookie(&http.Cookie{Name: "token", Value: beaToken})
|
|
expectUser("bearer before cookie", both, ada)
|
|
cookies := sessionRequest("token", beaToken)
|
|
cookies.AddCookie(&http.Cookie{Name: "auth_token", Value: adaToken})
|
|
expectUser("token before auth_token", cookies, bea)
|
|
blankBearer := sessionRequest("token", beaToken)
|
|
blankBearer.Header.Set("Authorization", "Bearer ")
|
|
expectUser("blank bearer falls back to the cookie", blankBearer, bea)
|
|
|
|
expectUser("anonymous", httptest.NewRequest(http.MethodGet, "/", nil), nil)
|
|
if u, err := classes.RequestUser(ctx, env.app, nil); u != nil || err != nil {
|
|
t.Fatalf("nil request: %v %v", u, err)
|
|
}
|
|
|
|
sub := strconv.FormatUint(uint64(ada.ID), 10)
|
|
expired, _, _ := bouncer.Mint(sessionSecret, sub, "https://app.example.test", -time.Minute)
|
|
expectUser("expired", sessionRequest("bearer", expired), nil)
|
|
forged, _, _ := bouncer.Mint("another-secret-of-the-right-length-xyz1", sub, "https://app.example.test", time.Hour)
|
|
expectUser("another secret", sessionRequest("bearer", forged), nil)
|
|
expectUser("garbage", sessionRequest("bearer", "not-a-jwt"), nil)
|
|
for _, s := range []string{"abc", "0", "-1"} {
|
|
odd, _, _ := bouncer.Mint(sessionSecret, s, "https://app.example.test", time.Hour)
|
|
expectUser("sub "+s, sessionRequest("bearer", odd), nil)
|
|
}
|
|
gone, _, _ := bouncer.Mint(sessionSecret, strconv.FormatUint(uint64(bea.ID+1_000_000), 10), "https://app.example.test", time.Hour)
|
|
expectUser("unknown user", sessionRequest("bearer", gone), nil)
|
|
|
|
// Blacklisted: the jti EndRequestSession revoked signs nobody in.
|
|
carl := env.seedUser(t, "carl", nil)
|
|
carlToken := issue(carl)
|
|
expectUser("carl before sign-out", sessionRequest("bearer", carlToken), carl)
|
|
if err := classes.EndRequestSession(ctx, env.app, httptest.NewRecorder(), sessionRequest("bearer", carlToken)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expectUser("blacklisted", sessionRequest("bearer", carlToken), nil)
|
|
|
|
// tokens_valid_after after the token's iat (a password change).
|
|
dora := env.seedUser(t, "dora", nil)
|
|
doraToken := issue(dora)
|
|
cutoff := time.Now().Add(time.Minute)
|
|
if err := env.gdb.Model(&models.User{}).Where("id = ?", dora.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expectUser("tokens_valid_after", sessionRequest("bearer", doraToken), nil)
|
|
|
|
// A soft-deleted (deactivated) user.
|
|
eve := env.seedUser(t, "eve", nil)
|
|
eveToken := issue(eve)
|
|
if err := env.gdb.Model(&models.User{}).Where("id = ?", eve.ID).Update("deleted_at", time.Now()).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expectUser("soft-deleted", sessionRequest("bearer", eveToken), nil)
|
|
|
|
// Only infrastructure failures are errors.
|
|
noDB := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
|
if u, err := classes.RequestUser(ctx, noDB, sessionRequest("bearer", adaToken)); u != nil || err == nil {
|
|
t.Fatalf("no database handle: %v %v", u, err)
|
|
}
|
|
noSecret := sessionUserApp(t)
|
|
if u, err := classes.RequestUser(ctx, noSecret, sessionRequest("bearer", adaToken)); u != nil || err == nil {
|
|
t.Fatalf("no JWT secret: %v %v", u, err)
|
|
}
|
|
if u, err := classes.RequestUser(ctx, noSecret, httptest.NewRequest(http.MethodGet, "/", nil)); u != nil || err != nil {
|
|
t.Fatalf("anonymous without a secret: %v %v", u, err)
|
|
}
|
|
broken := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
|
if err := broken.Publish(env.gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := broken.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u, err := classes.RequestUser(ctx, broken, sessionRequest("bearer", adaToken)); u != nil || !errors.Is(err, errBlacklistDown) {
|
|
t.Fatalf("failing blacklist: %v %v", u, err)
|
|
}
|
|
canceled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
if u, err := classes.RequestUser(canceled, env.app, sessionRequest("bearer", adaToken)); u != nil || err == nil {
|
|
t.Fatalf("failing query: %v %v", u, err)
|
|
}
|
|
// Without a published blacklist the token is accepted on its own checks.
|
|
plain := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
|
if err := plain.Publish[*gorm.DB](env.gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u, err := classes.RequestUser(ctx, plain, sessionRequest("bearer", adaToken)); err != nil || u == nil || u.ID != ada.ID {
|
|
t.Fatalf("without a blacklist: %v %v", u, err)
|
|
}
|
|
}
|
|
|
|
// TestEndRequestSession pins classes.EndRequestSession (D-04, D-05) without
|
|
// a database: the session's jti is blacklisted until the token expires, the
|
|
// forget cookie carries the configured Domain and Path, a request without a
|
|
// token still forgets the cookie, and a blacklist failure never fails the
|
|
// call.
|
|
func TestEndRequestSession(t *testing.T) {
|
|
ctx := t.Context()
|
|
app := sessionUserApp(t,
|
|
"SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret,
|
|
"SUMMER_AUTH__COOKIE__DOMAIN=app.example.test",
|
|
"SUMMER_AUTH__COOKIE__PATH=/app",
|
|
)
|
|
bl := bouncer.NewMemoryBlacklist()
|
|
if err := app.Publish[bouncer.BlacklistStore](bl); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
forgot := func(t *testing.T, rec *httptest.ResponseRecorder, domain, path string) {
|
|
t.Helper()
|
|
res := rec.Result()
|
|
var found *http.Cookie
|
|
for _, c := range res.Cookies() {
|
|
if c.Name == bouncer.DefaultCookieName {
|
|
found = c
|
|
}
|
|
}
|
|
if found == nil || found.Value != "" || found.MaxAge >= 0 || found.Domain != domain || found.Path != path {
|
|
t.Fatalf("forget cookie = %+v (Set-Cookie %q)", found, res.Header.Values("Set-Cookie"))
|
|
}
|
|
if rec.Body.Len() != 0 {
|
|
t.Fatalf("EndRequestSession wrote a body: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
for _, kind := range []string{"bearer", "token", "auth_token"} {
|
|
token, jti, err := bouncer.Mint(sessionSecret, "7", "https://app.example.test", time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
if err := classes.EndRequestSession(ctx, app, rec, sessionRequest(kind, token)); err != nil {
|
|
t.Fatalf("%s: %v", kind, err)
|
|
}
|
|
forgot(t, rec, "app.example.test", "/app")
|
|
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
|
|
t.Fatalf("%s: the jti is not blacklisted", kind)
|
|
}
|
|
// Until the token's expiry, and no longer.
|
|
_ = bl.Sweep(ctx, time.Now())
|
|
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
|
|
t.Fatalf("%s: the entry left before the token expired", kind)
|
|
}
|
|
_ = bl.Sweep(ctx, time.Now().Add(time.Hour+time.Minute))
|
|
if blocked, _ := bl.IsBlacklisted(ctx, jti); blocked {
|
|
t.Fatalf("%s: the entry outlived the token", kind)
|
|
}
|
|
}
|
|
|
|
// No token, a token of another secret, garbage: the cookie is forgotten
|
|
// and nothing is blacklisted.
|
|
foreign, foreignJTI, _ := bouncer.Mint("another-secret-of-the-right-length-xyz1", "7", "https://app.example.test", time.Hour)
|
|
for name, r := range map[string]*http.Request{
|
|
"no token": httptest.NewRequest(http.MethodPost, "/", nil),
|
|
"another secret": sessionRequest("bearer", foreign),
|
|
"garbage": sessionRequest("token", "not-a-jwt"),
|
|
} {
|
|
rec := httptest.NewRecorder()
|
|
if err := classes.EndRequestSession(ctx, app, rec, r); err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
forgot(t, rec, "app.example.test", "/app")
|
|
}
|
|
if blocked, _ := bl.IsBlacklisted(ctx, foreignJTI); blocked {
|
|
t.Fatal("a token of another secret was blacklisted")
|
|
}
|
|
|
|
// A blacklist failure is logged, never returned; the cookie still goes.
|
|
down := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret)
|
|
if err := down.Publish[bouncer.BlacklistStore](failingBlacklist{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, _, _ := bouncer.Mint(sessionSecret, "7", "https://app.example.test", time.Hour)
|
|
rec := httptest.NewRecorder()
|
|
if err := classes.EndRequestSession(ctx, down, rec, sessionRequest("bearer", token)); err != nil {
|
|
t.Fatalf("failing blacklist: %v", err)
|
|
}
|
|
forgot(t, rec, "", "/")
|
|
|
|
// Without an app or a writer it does what it can.
|
|
rec = httptest.NewRecorder()
|
|
if err := classes.EndRequestSession(ctx, nil, rec, sessionRequest("bearer", token)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
forgot(t, rec, "", "/")
|
|
if err := classes.EndRequestSession(ctx, app, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// An invalid cookie configuration falls back to the defaults.
|
|
badCookie := sessionUserApp(t, "SUMMER_GOLEM15__USER__JWT__SECRET="+sessionSecret, "SUMMER_AUTH__COOKIE__PATH=relative")
|
|
rec = httptest.NewRecorder()
|
|
if err := classes.EndRequestSession(ctx, badCookie, rec, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
forgot(t, rec, "", "/")
|
|
if !strings.Contains(strings.Join(rec.Result().Header.Values("Set-Cookie"), ";"), "token=") {
|
|
t.Fatal("no Set-Cookie for the token cookie")
|
|
}
|
|
}
|