test(14.1-02): prove oauth-identities migration and secret-leak list

- Migration up/down covers jsonb profile_data, unique indexes, and cascade FK
- GET list with Encrypted tokens fails if plaintext, key names, or [redacted] leak

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-05 21:34:00 +02:00
parent 026848fc02
commit f0d3b8b21d
2 changed files with 149 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import (
"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"
@@ -182,6 +183,66 @@ func TestOAuthIdentitiesDestroyWinter404BodiesAreByteIdentical(t *testing.T) {
}
}
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)

View File

@@ -0,0 +1,88 @@
package updates
import (
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"github.com/go-gormigrate/gormigrate/v2"
)
func TestOAuthIdentitiesMigration(t *testing.T) {
sqlDB := dedicatedDB(t, "user_oauth_identities")
db, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatal(err)
}
m := gormigrate.New(db, &gormigrate.Options{TableName: "summer_migrations_golem15_user", UseTransaction: true}, All())
if err := m.Migrate(); err != nil {
t.Fatal(err)
}
table := (&models.OAuthIdentity{}).TableName()
if table != "golem15_user_oauth_identities" {
t.Fatalf("TableName = %q", table)
}
if !db.Migrator().HasTable(table) {
t.Fatal("golem15_user_oauth_identities missing")
}
for _, column := range []string{
"id", "user_id", "provider", "provider_id", "access_token", "refresh_token",
"token_expires_at", "profile_data", "linked_at", "created_at", "updated_at",
} {
if !db.Migrator().HasColumn(&models.OAuthIdentity{}, column) {
t.Fatalf("golem15_user_oauth_identities.%s missing", column)
}
}
var udt string
if err := db.Raw(`SELECT udt_name FROM information_schema.columns WHERE table_name = 'golem15_user_oauth_identities' AND column_name = 'profile_data'`).Scan(&udt).Error; err != nil {
t.Fatal(err)
}
if udt != "jsonb" {
t.Fatalf("profile_data udt = %q, want jsonb", udt)
}
var indexes []string
if err := db.Raw(`SELECT indexname FROM pg_indexes WHERE tablename = 'golem15_user_oauth_identities'`).Scan(&indexes).Error; err != nil {
t.Fatal(err)
}
wantIdx := map[string]bool{
"oauth_identities_user_provider_unique": false,
"oauth_identities_provider_identity_unique": false,
}
for _, name := range indexes {
if _, ok := wantIdx[name]; ok {
wantIdx[name] = true
}
}
for name, found := range wantIdx {
if !found {
t.Fatalf("missing unique index %s in %v", name, indexes)
}
}
var fk struct {
ForeignTable string
DeleteRule string
}
if err := db.Raw(`
SELECT ccu.table_name AS foreign_table, rc.delete_rule
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
JOIN information_schema.referential_constraints rc
ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.table_schema
WHERE tc.table_name = 'golem15_user_oauth_identities'
AND tc.constraint_type = 'FOREIGN KEY'
AND kcu.column_name = 'user_id'`).Scan(&fk).Error; err != nil {
t.Fatal(err)
}
if fk.ForeignTable != "users" || fk.DeleteRule != "CASCADE" {
t.Fatalf("user_id FK = %+v, want users ON DELETE CASCADE", fk)
}
if err := m.RollbackMigration(oauthIdentityMigrations[0]); err != nil {
t.Fatal(err)
}
if db.Migrator().HasTable(table) {
t.Fatal("golem15_user_oauth_identities survived rollback")
}
}