package user import ( "context" "encoding/json" "net/http" "slices" "strconv" "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/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // requestCreatedAt reads when the administrator's open link request was // created. func (e *adminEnv) requestCreatedAt(t *testing.T, adminID uint) time.Time { t.Helper() var created time.Time if err := e.gdb.Raw(`SELECT created_at FROM golem15_user_backend_link_requests WHERE backend_user_id = ? AND used_at IS NULL ORDER BY id DESC LIMIT 1`, adminID).Scan(&created).Error; err != nil || created.IsZero() { t.Fatalf("link request created_at = %v, %v", created, err) } return created } // TestBackendLink (D-03..D-08) extends the link smokes: the fresh sign-in // rule at the second (an auth_time equal to the request's created_at // confirms, one second earlier is stale_session, and a token without // auth_time is stale_session whatever its iat); 1:1 on the frontend side too (a // linked user cannot confirm another administrator's request); the show // route exposes the administrator's login and name and the expiry only; // SetLink records linked_by and fires BackendLinkedEvent and both subject // events after commit, nothing on rollback; RemoveLink fires // BackendUnlinkedEvent and both subject events; LinkedBackendUser answers // false for a deactivated or soft-deleted administrator; deactivating the // frontend user removes the link and fires deleted. Skips under -short. func TestBackendLink(t *testing.T) { env := newAdminEnv(t) spy := env.spySubjects() links, _ := env.app.Lookup[*classes.BackendLinks]() resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]() ctx := context.Background() t.Run("fresh sign-in at the second", func(t *testing.T) { token := env.admin(t) admin := adminID(t, token) user := env.seedUser(t, "iat", nil) sub := strconv.FormatUint(uint64(user.ID), 10) code := env.startLink(t, token) created := env.requestCreatedAt(t, admin).Truncate(time.Second) early := craftSignInToken(t, sub, "early-"+adminStamp(), time.Now(), created.Add(-time.Second)) if status, body := env.confirmLink(t, early, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) { t.Fatalf("auth_time one second early = %d %s", status, body) } noAuthTime := craftToken(t, sub, "legacy-"+adminStamp(), time.Now(), time.Now().Add(time.Hour)) if status, body := env.confirmLink(t, noAuthTime, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) { t.Fatalf("no auth_time = %d %s", status, body) } exact := craftSignInToken(t, sub, "exact-"+adminStamp(), created, created) if status, body := env.confirmLink(t, exact, code); status != http.StatusOK { t.Fatalf("auth_time equal to created_at = %d %s", status, body) } }) t.Run("a linked frontend user cannot take a second administrator", func(t *testing.T) { first, second := env.admin(t), env.admin(t) user := env.seedUser(t, "one-to-one", nil) code := env.startLink(t, first) if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusOK { t.Fatalf("first link = %d %s", status, body) } code = env.startLink(t, second) if status, body := env.confirmLink(t, freshUserToken(t, user), code); status != http.StatusConflict || !strings.Contains(body, `"already_linked"`) { t.Fatalf("second administrator = %d %s", status, body) } }) t.Run("show exposes login, name and expiry only", func(t *testing.T) { token := env.admin(t) code := env.startLink(t, token) rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil) var shown map[string]map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil { t.Fatal(err) } keys := func(m map[string]any) []string { var out []string for k := range m { out = append(out, k) } slices.Sort(out) return out } admin, _ := shown["data"]["admin"].(map[string]any) if len(shown) != 1 || !slices.Equal(keys(shown["data"]), []string{"admin", "expires_at"}) || !slices.Equal(keys(admin), []string{"login", "name"}) { t.Fatalf("show = %s", rec.Body.String()) } if strings.Contains(rec.Body.String(), "@") || strings.Contains(rec.Body.String(), code) { t.Fatalf("show leaks an email or the code: %s", rec.Body.String()) } }) t.Run("SetLink and RemoveLink events", func(t *testing.T) { var mu sync.Mutex var linked, unlinked int env.app.Events.Listen("test.link_events", func(context.Context, *classes.BackendLinkedEvent) error { mu.Lock() linked++ mu.Unlock() return nil }) env.app.Events.Listen("test.link_events", func(context.Context, *classes.BackendUnlinkedEvent) error { mu.Lock() unlinked++ mu.Unlock() return nil }) setter := adminID(t, env.admin(t)) target := adminID(t, env.admin(t)) user := env.seedUser(t, "set-link", nil) // A rolled-back SetLink fires no subject event. spy.reset() _ = lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error { if _, err := links.SetLink(ctx, tx, user.ID, target, setter); err != nil { t.Fatal(err) } return context.Canceled }) if spy.has("user", user.ID, pact.MCPSubjectLink) || spy.has("backend", target, pact.MCPSubjectLink) { t.Fatalf("a rolled-back SetLink fired %+v", spy.events) } var link *models.BackendLink if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error { var err error link, err = links.SetLink(ctx, tx, user.ID, target, setter) return err }); err != nil { t.Fatal(err) } if link.LinkedBy == nil || *link.LinkedBy != setter { t.Fatalf("linked_by = %v, want %d", link.LinkedBy, setter) } if !spy.has("user", user.ID, pact.MCPSubjectLink) || !spy.has("backend", target, pact.MCPSubjectLink) { t.Fatalf("SetLink fired %+v", spy.events) } spy.reset() if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error { return links.RemoveLink(ctx, tx, link) }); err != nil { t.Fatal(err) } if !spy.has("user", user.ID, pact.MCPSubjectLink) || !spy.has("backend", target, pact.MCPSubjectLink) { t.Fatalf("RemoveLink fired %+v", spy.events) } mu.Lock() defer mu.Unlock() // The rolled-back SetLink fired BackendLinkedEvent inside its // transaction too (listeners run with the transaction). if linked != 2 || unlinked != 1 { t.Fatalf("BackendLinkedEvent %d, BackendUnlinkedEvent %d", linked, unlinked) } }) t.Run("deactivated or deleted administrator", func(t *testing.T) { token := env.admin(t) admin := adminID(t, token) user := env.seedUser(t, "admin-gone", nil) if status, body := env.confirmLink(t, freshUserToken(t, user), env.startLink(t, token)); status != http.StatusOK { t.Fatalf("link = %d %s", status, body) } check := func(want bool, why string) { t.Helper() if _, found, err := resolver.LinkedBackendUser(ctx, user.ID); err != nil || found != want { t.Fatalf("%s: found = %v, %v", why, found, err) } } check(true, "active") env.gdb.Exec(`UPDATE backend_users SET is_activated = FALSE WHERE id = ?`, admin) check(false, "deactivated administrator") env.gdb.Exec(`UPDATE backend_users SET is_activated = TRUE, deleted_at = NOW() WHERE id = ?`, admin) check(false, "soft-deleted administrator") env.gdb.Exec(`UPDATE backend_users SET deleted_at = NULL WHERE id = ?`, admin) check(true, "restored administrator") }) t.Run("deactivating the frontend user removes the link", func(t *testing.T) { token := env.admin(t) admin := adminID(t, token) user := env.seedUser(t, "deactivated", nil) if status, body := env.confirmLink(t, freshUserToken(t, user), env.startLink(t, token)); status != http.StatusOK { t.Fatalf("link = %d %s", status, body) } spy.reset() if err := lagoon.Transaction(ctx, env.gdb, func(ctx context.Context, tx *gorm.DB) error { return controllers.FireDeactivatingEvent(ctx, env.app, user, tx) }); err != nil { t.Fatal(err) } if link, err := links.LinkOf(ctx, user.ID); err != nil || link != nil { t.Fatalf("link after deactivation = %+v, %v", link, err) } if !spy.has("user", user.ID, pact.MCPSubjectDeleted) || !spy.has("backend", admin, pact.MCPSubjectLink) { t.Fatalf("deactivation fired %+v", spy.events) } }) }