diff --git a/oauth_identities_test.go b/oauth_identities_test.go
index 30c4f1f..24cf3a6 100644
--- a/oauth_identities_test.go
+++ b/oauth_identities_test.go
@@ -1,15 +1,40 @@
package user
import (
+ "bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"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/bouncer"
+ "git.golem15.com/golem15/summercms/modules/phrasebook"
+ "git.golem15.com/golem15/summercms/modules/towel"
+ "gorm.io/gorm"
+)
+
+const (
+ lastMethodBlockedEN = "This is the only remaining way to sign in. Link another method before disconnecting this one."
+ lastMethodBlockedPL = "To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę."
+ winter404Body = `
+
+
+
+ Nie znaleziono strony
+
+
+
+
+
Nie znaleziono strony
+
Żądana strona nie została znaleziona.
+
+
+`
)
func TestOAuthIdentitiesIndexEmptyList(t *testing.T) {
@@ -71,3 +96,179 @@ func TestOAuthIdentitiesDestroyNoContentKeepsSibling(t *testing.T) {
t.Fatalf("remaining = %#v", leftover)
}
}
+
+func TestOAuthIdentitiesDestroyLastMethodBlockedENAndPL(t *testing.T) {
+ app, db := sessionApp(t, "0s")
+ activateOAuthIdentitiesLang(t, app)
+ for _, tc := range []struct {
+ locale, email, want string
+ }{
+ {"en", "oauth-last-en@example.test", lastMethodBlockedEN},
+ {"pl", "oauth-last-pl@example.test", lastMethodBlockedPL},
+ } {
+ t.Run(tc.locale, func(t *testing.T) {
+ user := insertUser(t, db, tc.email, "correct-horse")
+ seedOAuthIdentity(t, db, user.ID, "facebook", "fb-only-"+tc.locale)
+ rec := destroyOAuthIdentity(t, app, user.ID, "facebook", tc.locale, winter404OrFatal(t))
+ 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 {
+ t.Fatal(err)
+ }
+ if body["error"] != tc.want {
+ t.Fatalf("golem15.user::lang.oauth.last_method_blocked %s = %#v", tc.locale, body["error"])
+ }
+ if remaining := countOAuthIdentities(t, db, user.ID); remaining != 1 {
+ t.Fatalf("row still present = %d", remaining)
+ }
+ })
+ }
+}
+
+func TestOAuthIdentitiesDestroyLastMethodBlockedWithPassword(t *testing.T) {
+ app, db := sessionApp(t, "0s")
+ activateOAuthIdentitiesLang(t, app)
+ user := insertUser(t, db, "oauth-last-password@example.test", "correct-horse")
+ if err := db.Model(user).Update("has_self_set_password", true).Error; err != nil {
+ t.Fatal(err)
+ }
+ seedOAuthIdentity(t, db, user.ID, "google", "g-only")
+ rec := destroyOAuthIdentity(t, app, user.ID, "google", "en", winter404OrFatal(t))
+ 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 {
+ t.Fatal(err)
+ }
+ if body["error"] != lastMethodBlockedEN {
+ t.Fatalf("last_method_blocked with password = %#v", body["error"])
+ }
+ if remaining := countOAuthIdentities(t, db, user.ID); remaining != 1 {
+ t.Fatalf("row still present = %d", remaining)
+ }
+}
+
+func TestOAuthIdentitiesDestroyWinter404BodiesAreByteIdentical(t *testing.T) {
+ app, db := sessionApp(t, "0s")
+ owner := insertUser(t, db, "oauth-404-owner@example.test", "correct-horse")
+ stranger := insertUser(t, db, "oauth-404-stranger@example.test", "correct-horse")
+ seedOAuthIdentity(t, db, owner.ID, "google", "g-owner")
+ seedOAuthIdentity(t, db, stranger.ID, "facebook", "fb-stranger")
+
+ foreign := destroyOAuthIdentity(t, app, stranger.ID, "google", "en", writeWinter404)
+ missing := destroyOAuthIdentity(t, app, stranger.ID, "github", "en", writeWinter404)
+ unknown := destroyOAuthIdentity(t, app, stranger.ID, "linkedin", "en", writeWinter404)
+ for name, rec := range map[string]*httptest.ResponseRecorder{
+ "foreign": foreign, "missing": missing, "unknown": unknown,
+ } {
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("%s status %d body %s", name, rec.Code, rec.Body.String())
+ }
+ if rec.Header().Get("Content-Type") != "text/html; charset=UTF-8" {
+ t.Fatalf("%s Content-Type = %q", name, rec.Header().Get("Content-Type"))
+ }
+ if !bytes.Equal(rec.Body.Bytes(), []byte(winter404Body)) {
+ t.Fatalf("%s body = %q", name, rec.Body.String())
+ }
+ }
+ if !bytes.Equal(foreign.Body.Bytes(), missing.Body.Bytes()) || !bytes.Equal(missing.Body.Bytes(), unknown.Body.Bytes()) {
+ t.Fatalf("404 bodies differ")
+ }
+ if remaining := countOAuthIdentities(t, db, owner.ID); remaining != 1 {
+ t.Fatalf("owner google row = %d", remaining)
+ }
+}
+
+func TestOAuthIdentitiesUnauthorizedOnGoogle(t *testing.T) {
+ app, _ := sessionApp(t, "0s")
+ getReq := httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/oauth-identities", nil)
+ getRec := httptest.NewRecorder()
+ controllers.OAuthIdentitiesIndex(app).ServeHTTP(getRec, getReq)
+ assertOAuthIdentityUnauthorized(t, "GET", getRec)
+
+ delReq := httptest.NewRequest(http.MethodDelete, "/_fonoteka/api/v1/oauth-identities/google", nil)
+ delReq.SetPathValue("provider", "google")
+ delRec := httptest.NewRecorder()
+ controllers.OAuthIdentitiesDestroy(app, controllers.OAuthIdentitiesOptions{
+ WriteNotFound: func(http.ResponseWriter, *http.Request) {
+ t.Fatal("unauthenticated /google must 401 before WriteNotFound")
+ },
+ }).ServeHTTP(delRec, delReq)
+ assertOAuthIdentityUnauthorized(t, "DELETE /google", delRec)
+}
+
+func activateOAuthIdentitiesLang(t *testing.T, app *backpack.App) {
+ t.Helper()
+ if err := phrasebook.Activate(app, []*Plugin{{}}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func seedOAuthIdentity(t *testing.T, db *gorm.DB, userID uint, provider, providerID string) {
+ t.Helper()
+ now := time.Now().UTC()
+ row := models.OAuthIdentity{
+ UserID: userID,
+ Provider: provider,
+ ProviderID: providerID,
+ LinkedAt: &now,
+ }
+ if err := db.Create(&row).Error; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func countOAuthIdentities(t *testing.T, db *gorm.DB, userID uint) int64 {
+ t.Helper()
+ var n int64
+ if err := db.Model(&models.OAuthIdentity{}).Where("user_id = ?", userID).Count(&n).Error; err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+func writeWinter404(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=UTF-8")
+ w.Header().Set("Cache-Control", "no-cache, private")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(winter404Body))
+}
+
+func winter404OrFatal(t *testing.T) func(http.ResponseWriter, *http.Request) {
+ t.Helper()
+ return func(http.ResponseWriter, *http.Request) {
+ t.Fatal("WriteNotFound called")
+ }
+}
+
+func destroyOAuthIdentity(t *testing.T, app *backpack.App, userID uint, provider, locale string, notFound func(http.ResponseWriter, *http.Request)) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodDelete, "/_fonoteka/api/v1/oauth-identities/"+provider, nil)
+ req.SetPathValue("provider", provider)
+ ctx := bouncer.WithUser(req.Context(), &bouncer.Principal{ID: userID})
+ if locale != "" {
+ ctx = towel.WithLocale(ctx, locale)
+ }
+ req = req.WithContext(ctx)
+ rec := httptest.NewRecorder()
+ controllers.OAuthIdentitiesDestroy(app, controllers.OAuthIdentitiesOptions{
+ WriteNotFound: notFound,
+ }).ServeHTTP(rec, req)
+ return rec
+}
+
+func assertOAuthIdentityUnauthorized(t *testing.T, name string, rec *httptest.ResponseRecorder) {
+ t.Helper()
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("%s status %d body %s", name, rec.Code, rec.Body.String())
+ }
+ if rec.Body.String() != `{"error":true,"message":"Unauthorized"}` {
+ t.Fatalf("%s body = %s", name, rec.Body.String())
+ }
+ if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
+ t.Fatalf("%s Cache-Control = %q", name, cc)
+ }
+}