- TestOAuthProviders: old bytes without a source, order, per-request lookup - TestOAuthIdentitiesDestroyRule: the PHP last-identity rule table, 404s, microsoft by default, source names and opts.Providers precedence - TestOAuthIdentitiesDestroyRace: two row-locked transactions in both orders, the handler waiting for the lock, concurrent handlers - TestRegisterUserPasswordLess, TestFinishLogin, TestRegistrationExports
332 lines
13 KiB
Go
332 lines
13 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|