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/lagoon" "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) { app, db := sessionApp(t, "0s") user := insertUser(t, db, "oauth-empty@example.test", "correct-horse") req := httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/oauth-identities", nil) req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID})) rec := httptest.NewRecorder() controllers.OAuthIdentitiesIndex(app).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } if rec.Body.String() != `{"data":[]}` { t.Fatalf("body = %s", rec.Body.String()) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if len(body) != 1 { t.Fatalf("top-level keys = %#v", body) } if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-cache") { t.Fatalf("Cache-Control = %q", cc) } } func TestOAuthIdentitiesDestroyNoContentKeepsSibling(t *testing.T) { app, db := sessionApp(t, "0s") user := insertUser(t, db, "oauth-unlink@example.test", "correct-horse") for _, row := range []models.OAuthIdentity{ {UserID: user.ID, Provider: "facebook", ProviderID: "fb-alice"}, {UserID: user.ID, Provider: "google", ProviderID: "goog-alice"}, } { if err := db.Create(&row).Error; err != nil { t.Fatal(err) } } req := httptest.NewRequest(http.MethodDelete, "/_fonoteka/api/v1/oauth-identities/facebook", nil) req.SetPathValue("provider", "facebook") req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID})) rec := httptest.NewRecorder() controllers.OAuthIdentitiesDestroy(app, controllers.OAuthIdentitiesOptions{ WriteNotFound: func(http.ResponseWriter, *http.Request) { t.Fatal("WriteNotFound called") }, }).ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } if rec.Body.Len() != 0 { t.Fatalf("body = %q", rec.Body.String()) } var leftover []models.OAuthIdentity if err := db.Where("user_id = ?", user.ID).Order("provider").Find(&leftover).Error; err != nil { t.Fatal(err) } if len(leftover) != 1 || leftover[0].Provider != "google" { 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 TestOAuthIdentitiesIndexDoesNotLeakEncryptedTokensOrProfileData(t *testing.T) { app, db := sessionApp(t, "0s") if err := lagoon.PublishEncryptionKeys(nil, bytes.Repeat([]byte("K"), 32), nil); err != nil { t.Fatal(err) } user := insertUser(t, db, "oauth-secret-leak@example.test", "correct-horse") const ( plaintext = "secret-access-token-oauth-leak-probe" refresh = "secret-refresh-token-oauth-leak-probe" marker = "profile-distinctive-xyzzy-oauth" ) now := time.Now().UTC() row := models.OAuthIdentity{ UserID: user.ID, Provider: "google", ProviderID: "g-secret-leak", AccessToken: lagoon.NewEncrypted(plaintext), RefreshToken: lagoon.NewEncrypted(refresh), ProfileData: lagoon.Jsonable[map[string]any]{ Data: map[string]any{"email": "leak@example.test", "marker": marker}, Valid: true, }, LinkedAt: &now, } if err := db.Create(&row).Error; err != nil { t.Fatal(err) } req := httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/oauth-identities", nil) req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID})) rec := httptest.NewRecorder() controllers.OAuthIdentitiesIndex(app).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, needle := range []string{plaintext, refresh, marker, "access_token", "refresh_token", "profile_data", "[redacted]"} { if strings.Contains(body, needle) { t.Fatalf("GET list leaked %q: %s", needle, body) } } var parsed struct { Data []map[string]any `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &parsed); err != nil { t.Fatal(err) } if len(parsed.Data) != 1 { t.Fatalf("data = %#v", parsed.Data) } if len(parsed.Data[0]) != 2 { t.Fatalf("row keys = %#v, want provider and linked_at only", parsed.Data[0]) } if parsed.Data[0]["provider"] != "google" { t.Fatalf("provider = %#v", parsed.Data[0]["provider"]) } if _, ok := parsed.Data[0]["linked_at"]; !ok { t.Fatalf("linked_at missing: %#v", parsed.Data[0]) } } 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) } }