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