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