From 026848fc026588d344a1de7e0bf43104fabff929 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 21:31:50 +0200 Subject: [PATCH] test(14.1-02): port OAuthIdentityApiTest 204, 409, Winter 404s, 401 - Unlink 204 keeps the sibling row - Last-method 409 matches EN and PL lang strings even with a password - Missing, foreign, and unknown providers share byte-identical Winter HTML 404s - Unauthenticated GET and DELETE /google return 401 JSON Co-authored-by: Cursor --- oauth_identities_test.go | 201 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) 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) + } +}