Files
sm-user-plugin/oauth_providers_test.go
Jakub Zych d6d8f117ea test: provider source, unlink rule and race, sign-in helpers
- TestOAuthProviders: old bytes without a source, order, per-request lookup
- TestOAuthIdentitiesDestroyRule: the PHP last-identity rule table, 404s,
  microsoft by default, source names and opts.Providers precedence
- TestOAuthIdentitiesDestroyRace: two row-locked transactions in both orders,
  the handler waiting for the lock, concurrent handlers
- TestRegisterUserPasswordLess, TestFinishLogin, TestRegistrationExports
2026-10-21 00:49:00 +02:00

137 lines
4.7 KiB
Go

package user
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// countingProviderSource answers a mutable provider list and counts the
// lookups, so a test can prove the handler asks on every request.
type countingProviderSource struct {
enabled atomic.Pointer[[]classes.OAuthProvider]
calls atomic.Int32
ctxSeen atomic.Bool
}
type ctxProbeKey struct{}
func (s *countingProviderSource) EnabledOAuthProviders(ctx context.Context) []classes.OAuthProvider {
s.calls.Add(1)
if ctx.Value(ctxProbeKey{}) == "probe" {
s.ctxSeen.Store(true)
}
if p := s.enabled.Load(); p != nil {
return *p
}
return nil
}
func (s *countingProviderSource) KnownOAuthProviders() []string { return nil }
// TestOAuthProviders pins GET /_user/api/v1/oauth-providers (D-15) without a
// database: the old bytes without a source, an empty list from a source
// with nothing enabled, the source's order, and a lookup on every request.
func TestOAuthProviders(t *testing.T) {
get := func(t *testing.T, h http.HandlerFunc, ctx context.Context) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/oauth-providers", nil)
if ctx != nil {
req = req.WithContext(ctx)
}
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
t.Fatalf("Cache-Control = %q", cc)
}
return rec
}
body := func(rec *httptest.ResponseRecorder) string {
return strings.TrimRight(rec.Body.String(), "\n")
}
t.Run("no_source_old_bytes", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("body = %s", got)
}
})
t.Run("source_without_enabled_providers", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
if err := app.Publish[classes.OAuthProviderSource](fakeProviderSource{known: []string{"google"}}); err != nil {
t.Fatal(err)
}
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("body = %s", got)
}
})
t.Run("order_preserved", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
src := fakeProviderSource{enabled: []classes.OAuthProvider{
{Name: "microsoft", Label: "Microsoft"},
{Name: "github", Label: "GitHub"},
{Name: "google", Label: "Google"},
}}
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
t.Fatal(err)
}
want := `{"providers":[{"label":"Microsoft","name":"microsoft"},{"label":"GitHub","name":"github"},{"label":"Google","name":"google"}],"success":true}`
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != want {
t.Fatalf("body = %s\nwant %s", got, want)
}
})
t.Run("lookup_per_request", func(t *testing.T) {
app := backpack.New(sessionConfig(t))
h := controllers.OAuthProviders(app)
if got := body(get(t, h, nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("before publishing: %s", got)
}
src := &countingProviderSource{}
if err := app.Publish[classes.OAuthProviderSource](src); err != nil {
t.Fatal(err)
}
first := []classes.OAuthProvider{{Name: "google", Label: "Google"}}
src.enabled.Store(&first)
probe := context.WithValue(context.Background(), ctxProbeKey{}, "probe")
if got := body(get(t, h, probe)); got != `{"providers":[{"label":"Google","name":"google"}],"success":true}` {
t.Fatalf("after publishing: %s", got)
}
second := []classes.OAuthProvider{{Name: "github", Label: "GitHub"}}
src.enabled.Store(&second)
if got := body(get(t, h, nil)); got != `{"providers":[{"label":"GitHub","name":"github"}],"success":true}` {
t.Fatalf("after the source changed: %s", got)
}
if n := src.calls.Load(); n != 2 {
t.Fatalf("EnabledOAuthProviders called %d times for 2 requests", n)
}
if !src.ctxSeen.Load() {
t.Fatal("EnabledOAuthProviders did not receive the request context")
}
})
t.Run("bare_publish_is_not_found", func(t *testing.T) {
// A source published without the explicit type argument is keyed on
// its concrete type; the list stays empty (the documented pitfall).
app := backpack.New(sessionConfig(t))
if err := app.Publish(fakeProviderSource{enabled: []classes.OAuthProvider{{Name: "google", Label: "Google"}}}); err != nil {
t.Fatal(err)
}
if got := body(get(t, controllers.OAuthProviders(app), nil)); got != `{"providers":[],"success":true}` {
t.Fatalf("body = %s", got)
}
})
}