Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7635c56e3 | ||
|
|
d6d8f117ea |
331
oauth_identities_rule_test.go
Normal file
331
oauth_identities_rule_test.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"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/towel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// guardedUnlinkSQL is the DELETE OAuthIdentitiesDestroy runs (PHP User.php
|
||||||
|
// unlinkOAuthProvider), copied so the race test drives the same statement
|
||||||
|
// under its own transactions.
|
||||||
|
const guardedUnlinkSQL = `DELETE FROM golem15_user_oauth_identities
|
||||||
|
WHERE user_id = ? AND provider = ?
|
||||||
|
AND (EXISTS (SELECT 1 FROM users u WHERE u.id = golem15_user_oauth_identities.user_id AND u.has_self_set_password)
|
||||||
|
OR (SELECT COUNT(*) FROM golem15_user_oauth_identities oi WHERE oi.user_id = golem15_user_oauth_identities.user_id) > 1)`
|
||||||
|
|
||||||
|
// ruleUser creates a user named after the test with selfSet as
|
||||||
|
// has_self_set_password and links the given providers.
|
||||||
|
func ruleUser(t *testing.T, db *gorm.DB, selfSet bool, providers ...string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
slug := strings.NewReplacer("/", "-", " ", "-").Replace(strings.ToLower(t.Name()))
|
||||||
|
email := fmt.Sprintf("%s-%d@example.test", slug, time.Now().UnixNano())
|
||||||
|
u := insertUser(t, db, email, "correct-horse")
|
||||||
|
if err := db.Model(u).Update("has_self_set_password", selfSet).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range providers {
|
||||||
|
seedOAuthIdentity(t, db, u.ID, p, p+"-"+email)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkedProviders(t *testing.T, db *gorm.DB, userID uint) []string {
|
||||||
|
t.Helper()
|
||||||
|
var rows []models.OAuthIdentity
|
||||||
|
if err := db.Where("user_id = ?", userID).Order("provider").Find(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, r.Provider)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlinkWith runs the destroy handler for userID and provider with opts.
|
||||||
|
func unlinkWith(t *testing.T, app *backpack.App, opts controllers.OAuthIdentitiesOptions, userID uint, provider, locale string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/_user/api/v1/oauth-identities/"+provider, nil)
|
||||||
|
req.SetPathValue("provider", provider)
|
||||||
|
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: userID}), locale))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
controllers.OAuthIdentitiesDestroy(app, opts).ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func notFound404(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte("host 404"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOAuthIdentitiesDestroyRule is the D-13 unlink rule table: an identity
|
||||||
|
// goes when the user has a self-set password or another identity remains;
|
||||||
|
// otherwise 409 with the unchanged EN and PL bodies. Unknown providers and
|
||||||
|
// identities of other users are 404 through the host's WriteNotFound;
|
||||||
|
// microsoft is in the default allow-list; without opts.Providers a
|
||||||
|
// published source's known names decide, and opts.Providers wins over it.
|
||||||
|
func TestOAuthIdentitiesDestroyRule(t *testing.T) {
|
||||||
|
skipShortPostgres(t)
|
||||||
|
app, db := sessionApp(t, "0s")
|
||||||
|
activateOAuthIdentitiesLang(t, app)
|
||||||
|
opts := controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
selfSet bool
|
||||||
|
linked []string
|
||||||
|
unlink string
|
||||||
|
locale string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
remaining []string
|
||||||
|
}{
|
||||||
|
{name: "self_set_password_one_identity", selfSet: true, linked: []string{"google"}, unlink: "google", status: http.StatusNoContent, remaining: []string{}},
|
||||||
|
{name: "passwordless_one_identity_en", linked: []string{"facebook"}, unlink: "facebook", locale: "en", status: http.StatusConflict, body: `{"error":"` + lastMethodBlockedEN + `"}`, remaining: []string{"facebook"}},
|
||||||
|
{name: "passwordless_one_identity_pl", linked: []string{"github"}, unlink: "github", locale: "pl", status: http.StatusConflict, body: `{"error":"` + lastMethodBlockedPL + `"}`, remaining: []string{"github"}},
|
||||||
|
{name: "passwordless_two_identities", linked: []string{"facebook", "google"}, unlink: "google", status: http.StatusNoContent, remaining: []string{"facebook"}},
|
||||||
|
{name: "self_set_password_two_identities", selfSet: true, linked: []string{"facebook", "google"}, unlink: "facebook", status: http.StatusNoContent, remaining: []string{"google"}},
|
||||||
|
{name: "unknown_provider", selfSet: true, linked: []string{"google"}, unlink: "linkedin", status: http.StatusNotFound, body: "host 404", remaining: []string{"google"}},
|
||||||
|
{name: "not_linked", selfSet: true, linked: []string{"google"}, unlink: "github", status: http.StatusNotFound, body: "host 404", remaining: []string{"google"}},
|
||||||
|
{name: "microsoft_allowed_by_default", selfSet: true, linked: []string{"microsoft"}, unlink: "microsoft", status: http.StatusNoContent, remaining: []string{}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
u := ruleUser(t, db, tc.selfSet, tc.linked...)
|
||||||
|
locale := tc.locale
|
||||||
|
if locale == "" {
|
||||||
|
locale = "en"
|
||||||
|
}
|
||||||
|
rec := unlinkWith(t, app, opts, u.ID, tc.unlink, locale)
|
||||||
|
if rec.Code != tc.status {
|
||||||
|
t.Fatalf("status %d, want %d (body %s)", rec.Code, tc.status, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := strings.TrimRight(rec.Body.String(), "\n"); got != tc.body {
|
||||||
|
t.Fatalf("body %q, want %q", got, tc.body)
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, u.ID); strings.Join(got, ",") != strings.Join(tc.remaining, ",") {
|
||||||
|
t.Fatalf("remaining %v, want %v", got, tc.remaining)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("foreign_identity", func(t *testing.T) {
|
||||||
|
owner := ruleUser(t, db, true, "google")
|
||||||
|
stranger := ruleUser(t, db, true, "facebook")
|
||||||
|
rec := unlinkWith(t, app, opts, stranger.ID, "google", "en")
|
||||||
|
if rec.Code != http.StatusNotFound || rec.Body.String() != "host 404" {
|
||||||
|
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, owner.ID); len(got) != 1 || got[0] != "google" {
|
||||||
|
t.Fatalf("owner's identities %v", got)
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, stranger.ID); len(got) != 1 || got[0] != "facebook" {
|
||||||
|
t.Fatalf("stranger's identities %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("source_known_names_when_no_options", func(t *testing.T) {
|
||||||
|
srcApp, srcDB := sessionApp(t, "0s")
|
||||||
|
if err := srcApp.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google", "custom"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u := ruleUser(t, srcDB, true, "custom", "github")
|
||||||
|
if rec := unlinkWith(t, srcApp, opts, u.ID, "custom", "en"); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("custom: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := unlinkWith(t, srcApp, opts, u.ID, "github", "en"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("github outside the source's names: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, srcDB, u.ID); len(got) != 1 || got[0] != "github" {
|
||||||
|
t.Fatalf("remaining %v, want [github]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("options_win_over_source", func(t *testing.T) {
|
||||||
|
srcApp, srcDB := sessionApp(t, "0s")
|
||||||
|
if err := srcApp.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google", "custom"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
withList := controllers.OAuthIdentitiesOptions{Providers: []string{"github"}, WriteNotFound: notFound404}
|
||||||
|
u := ruleUser(t, srcDB, true, "custom", "github")
|
||||||
|
if rec := unlinkWith(t, srcApp, withList, u.ID, "custom", "en"); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("custom outside opts.Providers: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := unlinkWith(t, srcApp, withList, u.ID, "github", "en"); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("github in opts.Providers: status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, srcDB, u.ID); len(got) != 1 || got[0] != "custom" {
|
||||||
|
t.Fatalf("remaining %v, want [custom]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOAuthIdentitiesDestroyRace forces the D-13 race deterministically:
|
||||||
|
// two transactions unlink the two identities of a password-less user, each
|
||||||
|
// taking the users-row lock (SELECT ... FOR UPDATE) and running the guarded
|
||||||
|
// DELETE the handler runs. The second blocks until the first commits and
|
||||||
|
// then deletes nothing, in both orders; exactly one identity is left. The
|
||||||
|
// handler variants prove the handler takes the same lock.
|
||||||
|
func TestOAuthIdentitiesDestroyRace(t *testing.T) {
|
||||||
|
skipShortPostgres(t)
|
||||||
|
app, db := sessionApp(t, "0s")
|
||||||
|
activateOAuthIdentitiesLang(t, app)
|
||||||
|
|
||||||
|
for _, order := range [][2]string{{"google", "facebook"}, {"facebook", "google"}} {
|
||||||
|
first, second := order[0], order[1]
|
||||||
|
t.Run("transactions_"+first+"_first", func(t *testing.T) {
|
||||||
|
u := ruleUser(t, db, false, "google", "facebook")
|
||||||
|
tx1 := db.Begin()
|
||||||
|
if tx1.Error != nil {
|
||||||
|
t.Fatal(tx1.Error)
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !committed {
|
||||||
|
tx1.Rollback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := tx1.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res := tx1.Exec(guardedUnlinkSQL, u.ID, first)
|
||||||
|
if res.Error != nil || res.RowsAffected != 1 {
|
||||||
|
t.Fatalf("first delete: rows %d err %v", res.RowsAffected, res.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type outcome struct {
|
||||||
|
rows int64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
done := make(chan outcome, 1)
|
||||||
|
go func() {
|
||||||
|
var rows int64
|
||||||
|
err := db.Transaction(func(tx2 *gorm.DB) error {
|
||||||
|
if err := tx2.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := tx2.Exec(guardedUnlinkSQL, u.ID, second)
|
||||||
|
rows = r.RowsAffected
|
||||||
|
return r.Error
|
||||||
|
})
|
||||||
|
done <- outcome{rows, err}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
t.Fatalf("the second transaction finished (rows %d, err %v) while the first held the users row lock", got.rows, got.err)
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := tx1.Commit().Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
if got.err != nil || got.rows != 0 {
|
||||||
|
t.Fatalf("second delete after the first committed: rows %d err %v, want 0 rows", got.rows, got.err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the second transaction did not finish after the first committed")
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, u.ID); len(got) != 1 || got[0] != second {
|
||||||
|
t.Fatalf("remaining %v, want [%s]", got, second)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handler_waits_"+first+"_first", func(t *testing.T) {
|
||||||
|
u := ruleUser(t, db, false, "google", "facebook")
|
||||||
|
tx1 := db.Begin()
|
||||||
|
committed := false
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !committed {
|
||||||
|
tx1.Rollback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := tx1.Exec(`SELECT id FROM users WHERE id = ? FOR UPDATE`, u.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res := tx1.Exec(guardedUnlinkSQL, u.ID, first); res.Error != nil || res.RowsAffected != 1 {
|
||||||
|
t.Fatalf("first delete: rows %d err %v", res.RowsAffected, res.Error)
|
||||||
|
}
|
||||||
|
recCh := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() {
|
||||||
|
recCh <- unlinkWith(t, app, controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}, u.ID, second, "en")
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case rec := <-recCh:
|
||||||
|
t.Fatalf("the handler answered %d while the users row was locked", rec.Code)
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if err := tx1.Commit().Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
var rec *httptest.ResponseRecorder
|
||||||
|
select {
|
||||||
|
case rec = <-recCh:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the handler did not answer after the lock was released")
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
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 || body["error"] != lastMethodBlockedEN {
|
||||||
|
t.Fatalf("409 body %s (%v)", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, u.ID); len(got) != 1 || got[0] != second {
|
||||||
|
t.Fatalf("remaining %v, want [%s]", got, second)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("concurrent_handlers", func(t *testing.T) {
|
||||||
|
opts := controllers.OAuthIdentitiesOptions{WriteNotFound: notFound404}
|
||||||
|
for round := range 10 {
|
||||||
|
u := ruleUser(t, db, false, "google", "facebook")
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
start := make(chan struct{})
|
||||||
|
codes := make([]int, 2)
|
||||||
|
for i, p := range []string{"google", "facebook"} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
codes[i] = unlinkWith(t, app, opts, u.ID, p, "en").Code
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
ok, conflict := 0, 0
|
||||||
|
for _, c := range codes {
|
||||||
|
switch c {
|
||||||
|
case http.StatusNoContent:
|
||||||
|
ok++
|
||||||
|
case http.StatusConflict:
|
||||||
|
conflict++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok != 1 || conflict != 1 {
|
||||||
|
t.Fatalf("round %d: statuses %v, want one 204 and one 409", round, codes)
|
||||||
|
}
|
||||||
|
if got := linkedProviders(t, db, u.ID); len(got) != 1 {
|
||||||
|
t.Fatalf("round %d: remaining %v, want exactly one identity", round, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
136
oauth_providers_test.go
Normal file
136
oauth_providers_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countingProviderSource answers a mutable provider list and counts the
|
||||||
|
// lookups, so a test can prove the handler asks on every request.
|
||||||
|
type countingProviderSource struct {
|
||||||
|
enabled atomic.Pointer[[]classes.OAuthProvider]
|
||||||
|
calls atomic.Int32
|
||||||
|
ctxSeen atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxProbeKey struct{}
|
||||||
|
|
||||||
|
func (s *countingProviderSource) EnabledOAuthProviders(ctx context.Context) []classes.OAuthProvider {
|
||||||
|
s.calls.Add(1)
|
||||||
|
if ctx.Value(ctxProbeKey{}) == "probe" {
|
||||||
|
s.ctxSeen.Store(true)
|
||||||
|
}
|
||||||
|
if p := s.enabled.Load(); p != nil {
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *countingProviderSource) KnownOAuthProviders() []string { return nil }
|
||||||
|
|
||||||
|
// TestOAuthProviders pins GET /_user/api/v1/oauth-providers (D-15) without a
|
||||||
|
// database: the old bytes without a source, an empty list from a source
|
||||||
|
// with nothing enabled, the source's order, and a lookup on every request.
|
||||||
|
func TestOAuthProviders(t *testing.T) {
|
||||||
|
get := func(t *testing.T, h http.HandlerFunc, ctx context.Context) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/oauth-providers", nil)
|
||||||
|
if ctx != nil {
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
|
||||||
|
t.Fatalf("Cache-Control = %q", cc)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
body := func(rec *httptest.ResponseRecorder) string {
|
||||||
|
return strings.TrimRight(rec.Body.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no_source_old_bytes", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
|
||||||
|
t.Fatalf("body = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("source_without_enabled_providers", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if err := app.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
|
||||||
|
t.Fatalf("body = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("order_preserved", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
src := fakeProviderSource{enabled: []classes.OAuthProvider{
|
||||||
|
{Name: "microsoft", Label: "Microsoft"},
|
||||||
|
{Name: "github", Label: "GitHub"},
|
||||||
|
{Name: "google", Label: "Google"},
|
||||||
|
}}
|
||||||
|
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := `{"providers":[{"label":"Microsoft","name":"microsoft"},{"label":"GitHub","name":"github"},{"label":"Google","name":"google"}],"success":true}`
|
||||||
|
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != want {
|
||||||
|
t.Fatalf("body = %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("lookup_per_request", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
h := controllers.OAuthProviders(app)
|
||||||
|
if got := body(get(t, h, nil)); got != `{"providers":[],"success":true}` {
|
||||||
|
t.Fatalf("before publishing: %s", got)
|
||||||
|
}
|
||||||
|
src := &countingProviderSource{}
|
||||||
|
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := []classes.OAuthProvider{{Name: "google", Label: "Google"}}
|
||||||
|
src.enabled.Store(&first)
|
||||||
|
probe := context.WithValue(context.Background(), ctxProbeKey{}, "probe")
|
||||||
|
if got := body(get(t, h, probe)); got != `{"providers":[{"label":"Google","name":"google"}],"success":true}` {
|
||||||
|
t.Fatalf("after publishing: %s", got)
|
||||||
|
}
|
||||||
|
second := []classes.OAuthProvider{{Name: "github", Label: "GitHub"}}
|
||||||
|
src.enabled.Store(&second)
|
||||||
|
if got := body(get(t, h, nil)); got != `{"providers":[{"label":"GitHub","name":"github"}],"success":true}` {
|
||||||
|
t.Fatalf("after the source changed: %s", got)
|
||||||
|
}
|
||||||
|
if n := src.calls.Load(); n != 2 {
|
||||||
|
t.Fatalf("EnabledOAuthProviders called %d times for 2 requests", n)
|
||||||
|
}
|
||||||
|
if !src.ctxSeen.Load() {
|
||||||
|
t.Fatal("EnabledOAuthProviders did not receive the request context")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bare_publish_is_not_found", func(t *testing.T) {
|
||||||
|
// A source published without the explicit type argument is keyed on
|
||||||
|
// its concrete type; the list stays empty (the documented pitfall).
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if err := app.Publish(fakeProviderSource{enabled: []classes.OAuthProvider{{Name: "google", Label: "Google"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
|
||||||
|
t.Fatalf("body = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -82,6 +82,29 @@ func TestChangePassword(t *testing.T) {
|
|||||||
t.Fatalf("same password %d %s", same.Code, same.Body.String())
|
t.Fatalf("same password %d %s", same.Code, same.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PHP OAuthPasswordBootstrapTest::test_real_password_account_still_requires_current_password:
|
||||||
|
// a self-set password still needs current_password, and a wrong one is
|
||||||
|
// refused with the unchanged invalid_login body; the password stays.
|
||||||
|
t.Run("real_password_account_still_requires_current_password", func(t *testing.T) {
|
||||||
|
missing := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"password":"another-password-2","password_confirmation":"another-password-2"}`)
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(missing.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
errs, _ := body["errors"].(map[string]any)
|
||||||
|
if missing.Code != http.StatusUnprocessableEntity || errs["current_password"] == nil {
|
||||||
|
t.Fatalf("missing current_password %d %s", missing.Code, missing.Body.String())
|
||||||
|
}
|
||||||
|
wrong := bearerJSON(t, controllers.ChangePassword(app), "/_user/api/v1/change-password", token, `{"current_password":"definitely-wrong","password":"another-password-2","password_confirmation":"another-password-2"}`)
|
||||||
|
if wrong.Code != http.StatusUnprocessableEntity || wrong.Body.String() != `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}` {
|
||||||
|
t.Fatalf("wrong current_password %d %s", wrong.Code, wrong.Body.String())
|
||||||
|
}
|
||||||
|
again := postJSON(t, controllers.Login(app), "/_user/api/v1/login", `{"email":"change@example.test","password":"brand-new-password"}`)
|
||||||
|
if again.Code != http.StatusOK {
|
||||||
|
t.Fatalf("the password changed: login %d %s", again.Code, again.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
locked := insertUser(t, gdb, "locked-password@example.test", "correct-horse")
|
locked := insertUser(t, gdb, "locked-password@example.test", "correct-horse")
|
||||||
if err := gdb.Model(locked).UpdateColumn("has_self_set_password", false).Error; err != nil {
|
if err := gdb.Model(locked).UpdateColumn("has_self_set_password", false).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
332
social_hooks_test.go
Normal file
332
social_hooks_test.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/postcard"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// skipShortPostgres skips a Postgres-backed test under -short (the phase
|
||||||
|
// gate runs these by name without -short on a Docker host).
|
||||||
|
func skipShortPostgres(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres-backed; skipped under -short")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hookEmail is a unique address for one test.
|
||||||
|
func hookEmail(prefix string) string {
|
||||||
|
return fmt.Sprintf("%s-%d@example.test", prefix, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerFields(email string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"email": email,
|
||||||
|
"name": "Social Person",
|
||||||
|
"password": "random-password-never-shown",
|
||||||
|
"password_confirmation": "random-password-never-shown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterUserPasswordLess pins RegisterOptions.PasswordLess (D-22): the
|
||||||
|
// zero value keeps has_self_set_password true, true stores false; the handle
|
||||||
|
// is assigned either way; validation errors are the same with and without
|
||||||
|
// it and nothing is written.
|
||||||
|
func TestRegisterUserPasswordLess(t *testing.T) {
|
||||||
|
skipShortPostgres(t)
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
ctx := t.Context()
|
||||||
|
stored := func(t *testing.T, id uint) models.User {
|
||||||
|
t.Helper()
|
||||||
|
var u models.User
|
||||||
|
if err := gdb.First(&u, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("password_less_false_keeps_self_set", func(t *testing.T) {
|
||||||
|
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail("pwless-false")), controllers.RegisterOptions{Activate: true})
|
||||||
|
if err != nil || len(errs) > 0 {
|
||||||
|
t.Fatalf("RegisterUser: %v %v", err, errs)
|
||||||
|
}
|
||||||
|
if got := stored(t, u.ID); !got.HasSelfSetPassword || !u.HasSelfSetPassword {
|
||||||
|
t.Fatalf("has_self_set_password stored %v (returned %v), want true", got.HasSelfSetPassword, u.HasSelfSetPassword)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("password_less_true_stores_false", func(t *testing.T) {
|
||||||
|
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail("pwless-true")), controllers.RegisterOptions{Activate: true, PasswordLess: true, IP: "198.18.7.1"})
|
||||||
|
if err != nil || len(errs) > 0 {
|
||||||
|
t.Fatalf("RegisterUser: %v %v", err, errs)
|
||||||
|
}
|
||||||
|
got := stored(t, u.ID)
|
||||||
|
if got.HasSelfSetPassword || u.HasSelfSetPassword {
|
||||||
|
t.Fatalf("has_self_set_password stored %v (returned %v), want false", got.HasSelfSetPassword, u.HasSelfSetPassword)
|
||||||
|
}
|
||||||
|
if !got.IsActivated || got.ActivatedAt == nil {
|
||||||
|
t.Fatalf("activation %v %v", got.IsActivated, got.ActivatedAt)
|
||||||
|
}
|
||||||
|
if got.CreatedIPAddress == nil || *got.CreatedIPAddress != "198.18.7.1" || got.LastIPAddress == nil || *got.LastIPAddress != "198.18.7.1" {
|
||||||
|
t.Fatalf("ip columns %v %v", got.CreatedIPAddress, got.LastIPAddress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handle_assigned", func(t *testing.T) {
|
||||||
|
for _, pl := range []bool{false, true} {
|
||||||
|
u, errs, err := controllers.RegisterUser(ctx, app, gdb, registerFields(hookEmail(fmt.Sprintf("pwless-handle-%v", pl))), controllers.RegisterOptions{PasswordLess: pl})
|
||||||
|
if err != nil || len(errs) > 0 {
|
||||||
|
t.Fatalf("RegisterUser(PasswordLess=%v): %v %v", pl, err, errs)
|
||||||
|
}
|
||||||
|
if got := stored(t, u.ID); got.Handle == nil || *got.Handle == "" {
|
||||||
|
t.Fatalf("PasswordLess=%v: no handle", pl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("validation_errors_unchanged", func(t *testing.T) {
|
||||||
|
email := "not-an-email"
|
||||||
|
fields := map[string]any{"email": email, "password": "short", "password_confirmation": "other"}
|
||||||
|
_, plain, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{})
|
||||||
|
if err != nil || len(plain) == 0 {
|
||||||
|
t.Fatalf("no validation errors without PasswordLess: %v %v", err, plain)
|
||||||
|
}
|
||||||
|
u, less, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{PasswordLess: true})
|
||||||
|
if err != nil || u != nil {
|
||||||
|
t.Fatalf("PasswordLess registration with invalid fields: user %v err %v", u, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(plain, less) {
|
||||||
|
t.Fatalf("validation errors differ:\n without %v\n with %v", plain, less)
|
||||||
|
}
|
||||||
|
if _, ok := less["email"]; !ok {
|
||||||
|
t.Fatalf("no email error: %v", less)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if err := gdb.Unscoped().Model(&models.User{}).Where("email = ?", email).Count(&n).Error; err != nil || n != 0 {
|
||||||
|
t.Fatalf("an invalid registration wrote %d rows (%v)", n, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFinishLogin pins the social sign-in tail of a password login: a
|
||||||
|
// trashed account is restored with exactly one reactivate mail, an active
|
||||||
|
// one gets none; last_ip_address and last_seen are written, and an empty
|
||||||
|
// ip leaves last_ip_address alone.
|
||||||
|
func TestFinishLogin(t *testing.T) {
|
||||||
|
skipShortPostgres(t)
|
||||||
|
newApp := func(t *testing.T) (*backpack.App, *gorm.DB, *captureMail) {
|
||||||
|
t.Helper()
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
mail := &captureMail{}
|
||||||
|
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app, gdb, mail
|
||||||
|
}
|
||||||
|
load := func(t *testing.T, gdb *gorm.DB, id uint) models.User {
|
||||||
|
t.Helper()
|
||||||
|
var u models.User
|
||||||
|
if err := gdb.Unscoped().First(&u, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
newUser := func(t *testing.T, gdb *gorm.DB, prefix string) *models.User {
|
||||||
|
t.Helper()
|
||||||
|
loc := "en"
|
||||||
|
u := &models.User{Email: hookEmail(prefix), Password: "x", PreferredLocale: &loc, IsActivated: true}
|
||||||
|
if err := gdb.Create(u).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("trashed_user_restored_with_one_mail", func(t *testing.T) {
|
||||||
|
app, gdb, mail := newApp(t)
|
||||||
|
u := newUser(t, gdb, "finish-trashed")
|
||||||
|
if err := gdb.Model(u).Update("deleted_at", time.Now()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
trashed := load(t, gdb, u.ID)
|
||||||
|
if !trashed.DeletedAt.Valid {
|
||||||
|
t.Fatal("setup: not soft-deleted")
|
||||||
|
}
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, &trashed, "198.18.8.1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after := load(t, gdb, u.ID); after.DeletedAt.Valid {
|
||||||
|
t.Fatal("not restored")
|
||||||
|
}
|
||||||
|
if trashed.DeletedAt.Valid {
|
||||||
|
t.Fatal("the passed user still reads as trashed")
|
||||||
|
}
|
||||||
|
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.reactivate-en" {
|
||||||
|
t.Fatalf("mails %#v, want one reactivate-en", mail.got)
|
||||||
|
}
|
||||||
|
// A second sign-in of the restored user sends no further mail.
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, &trashed, "198.18.8.1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(mail.got) != 1 {
|
||||||
|
t.Fatalf("mails after a second sign-in %#v", mail.got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("active_user_no_mail", func(t *testing.T) {
|
||||||
|
app, gdb, mail := newApp(t)
|
||||||
|
u := newUser(t, gdb, "finish-active")
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, u, "198.18.8.2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(mail.got) != 0 {
|
||||||
|
t.Fatalf("mails %#v, want none", mail.got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ip_and_last_seen", func(t *testing.T) {
|
||||||
|
app, gdb, _ := newApp(t)
|
||||||
|
u := newUser(t, gdb, "finish-ip")
|
||||||
|
before := time.Now().Add(-time.Second)
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, u, "198.18.8.3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after := load(t, gdb, u.ID)
|
||||||
|
if after.LastIPAddress == nil || *after.LastIPAddress != "198.18.8.3" {
|
||||||
|
t.Fatalf("last_ip_address %v", after.LastIPAddress)
|
||||||
|
}
|
||||||
|
if u.LastIPAddress == nil || *u.LastIPAddress != "198.18.8.3" {
|
||||||
|
t.Fatalf("the passed user's LastIPAddress %v", u.LastIPAddress)
|
||||||
|
}
|
||||||
|
if after.LastSeen == nil || after.LastSeen.Before(before) {
|
||||||
|
t.Fatalf("last_seen %v, want at or after %v", after.LastSeen, before)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty_ip_keeps_last_ip", func(t *testing.T) {
|
||||||
|
app, gdb, _ := newApp(t)
|
||||||
|
u := newUser(t, gdb, "finish-noip")
|
||||||
|
if err := gdb.Model(u).Update("last_ip_address", "198.18.8.4").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, u, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after := load(t, gdb, u.ID); after.LastIPAddress == nil || *after.LastIPAddress != "198.18.8.4" {
|
||||||
|
t.Fatalf("last_ip_address %v, want the old 198.18.8.4", after.LastIPAddress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refuses_unsaved_user", func(t *testing.T) {
|
||||||
|
app, gdb, _ := newApp(t)
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, gdb, &models.User{}, "198.18.8.5"); err == nil {
|
||||||
|
t.Fatal("FinishLogin accepted a user without an id")
|
||||||
|
}
|
||||||
|
if err := controllers.FinishLogin(t.Context(), app, nil, &models.User{ID: 1}, ""); err == nil {
|
||||||
|
t.Fatal("FinishLogin accepted a nil database")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistrationExports pins the helpers golem15.user exports for a social
|
||||||
|
// login plugin: RegistrationSettings (defaults and config), RegistrationsFromIP
|
||||||
|
// (the last 60 minutes, that IP only), RefreshTTL (default 20160 minutes and
|
||||||
|
// config) and RemoteIP (with and without a port).
|
||||||
|
func TestRegistrationExports(t *testing.T) {
|
||||||
|
t.Run("registration_settings", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
allow, throttle, mode, require := controllers.RegistrationSettings(app)
|
||||||
|
if !allow || !throttle || mode != "auto" || !require {
|
||||||
|
t.Fatalf("defaults = %v %v %q %v, want true true auto true", allow, throttle, mode, require)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]any{
|
||||||
|
"golem15.user.registration.allow_registration": false,
|
||||||
|
"golem15.user.registration.use_register_throttle": false,
|
||||||
|
"golem15.user.activation.activate_mode": "admin",
|
||||||
|
"golem15.user.activation.require_activation": false,
|
||||||
|
} {
|
||||||
|
if err := app.Config.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allow, throttle, mode, require = controllers.RegistrationSettings(app)
|
||||||
|
if allow || throttle || mode != "admin" || require {
|
||||||
|
t.Fatalf("configured = %v %v %q %v, want false false admin false", allow, throttle, mode, require)
|
||||||
|
}
|
||||||
|
if a, th, m, r := controllers.RegistrationSettings(nil); !a || !th || m != "auto" || !r {
|
||||||
|
t.Fatalf("nil app = %v %v %q %v", a, th, m, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("registrations_from_ip", func(t *testing.T) {
|
||||||
|
skipShortPostgres(t)
|
||||||
|
_, gdb := sessionApp(t, "0s")
|
||||||
|
ip := fmt.Sprintf("198.18.%d.%d", time.Now().UnixNano()%200+20, time.Now().UnixNano()%250+1)
|
||||||
|
other := "198.19.0.1"
|
||||||
|
mk := func(addr string, age time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
u := &models.User{Email: hookEmail("regip"), Password: "x", CreatedIPAddress: &addr}
|
||||||
|
if err := gdb.Create(u).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if age > 0 {
|
||||||
|
if err := gdb.Exec(`UPDATE users SET created_at = ? WHERE id = ?`, time.Now().Add(-age), u.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before := controllers.RegistrationsFromIP(t.Context(), gdb, ip)
|
||||||
|
mk(ip, 0)
|
||||||
|
mk(ip, 30*time.Minute)
|
||||||
|
mk(ip, 61*time.Minute)
|
||||||
|
mk(ip, 3*time.Hour)
|
||||||
|
mk(other, 0)
|
||||||
|
if got := controllers.RegistrationsFromIP(t.Context(), gdb, ip); got-before != 2 {
|
||||||
|
t.Fatalf("RegistrationsFromIP = %d (before %d), want 2 more: the last 60 minutes only", got, before)
|
||||||
|
}
|
||||||
|
if got := controllers.RegistrationsFromIP(t.Context(), gdb, "198.19.255.254"); got != 0 {
|
||||||
|
t.Fatalf("an unused ip counts %d", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("refresh_ttl", func(t *testing.T) {
|
||||||
|
app := backpack.New(sessionConfig(t))
|
||||||
|
if got := controllers.RefreshTTL(app); got != 20160*time.Minute {
|
||||||
|
t.Fatalf("default RefreshTTL = %s, want 20160m", got)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set("golem15.user.jwt.refresh_ttl", 90); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := controllers.RefreshTTL(app); got != 90*time.Minute {
|
||||||
|
t.Fatalf("configured RefreshTTL = %s, want 90m", got)
|
||||||
|
}
|
||||||
|
if got := controllers.RefreshTTL(nil); got != 20160*time.Minute {
|
||||||
|
t.Fatalf("nil app RefreshTTL = %s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("remote_ip", func(t *testing.T) {
|
||||||
|
for remote, want := range map[string]string{
|
||||||
|
"203.0.113.9:4242": "203.0.113.9",
|
||||||
|
"203.0.113.9": "203.0.113.9",
|
||||||
|
"[2001:db8::1]:443": "2001:db8::1",
|
||||||
|
"": "",
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = remote
|
||||||
|
req.Header.Set("X-Forwarded-For", "192.0.2.200")
|
||||||
|
if got := controllers.RemoteIP(req); got != want {
|
||||||
|
t.Fatalf("RemoteIP(%q) = %q, want %q", remote, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user