// Unit tests for the Supabase admin HTTP client. The client is a thin // shim over net/http; coverage lives at the wire-shape level: header // presence, request method, body decode, status-code → error mapping. // No live Supabase call — every test runs against an httptest.Server. package services import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/google/uuid" ) func TestSupabaseAdminClient_Disabled(t *testing.T) { c := NewSupabaseAdminClient("https://example.invalid", "") if c.Enabled() { t.Fatal("Enabled() must be false when service-role key is empty") } ctx := context.Background() if _, err := c.CreateAuthUser(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) { t.Errorf("CreateAuthUser must return ErrSupabaseAdminUnavailable, got %v", err) } if _, err := c.GenerateRecoveryLink(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) { t.Errorf("GenerateRecoveryLink must return ErrSupabaseAdminUnavailable, got %v", err) } if err := c.DeleteAuthUser(ctx, uuid.New()); !errors.Is(err, ErrSupabaseAdminUnavailable) { t.Errorf("DeleteAuthUser must return ErrSupabaseAdminUnavailable, got %v", err) } } // TestSupabaseAdminClient_CreateAuthUser_Happy pins the wire-shape: // POST /auth/v1/admin/users, JSON body with email_confirm=true, both // apikey + Authorization headers present, parses the response id. func TestSupabaseAdminClient_CreateAuthUser_Happy(t *testing.T) { wantID := uuid.New() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("method = %q, want POST", r.Method) } if r.URL.Path != "/auth/v1/admin/users" { t.Errorf("path = %q, want /auth/v1/admin/users", r.URL.Path) } if r.Header.Get("apikey") != "service-key" { t.Errorf("missing apikey header") } if r.Header.Get("Authorization") != "Bearer service-key" { t.Errorf("missing Bearer header") } body, _ := io.ReadAll(r.Body) var got map[string]any _ = json.Unmarshal(body, &got) if got["email"] != "x@hlc.com" { t.Errorf("email = %v, want x@hlc.com", got["email"]) } if got["email_confirm"] != true { t.Errorf("email_confirm = %v, want true", got["email_confirm"]) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{"id": wantID.String()}) })) defer srv.Close() c := NewSupabaseAdminClient(srv.URL, "service-key") gotID, err := c.CreateAuthUser(context.Background(), " X@HLC.COM ") if err != nil { t.Fatalf("CreateAuthUser: %v", err) } if gotID != wantID { t.Errorf("id = %s, want %s", gotID, wantID) } } // TestSupabaseAdminClient_CreateAuthUser_EmailExists pins the 422-with- // "already" body → ErrSupabaseEmailExists translation. Mapped to 409 by // the handler. func TestSupabaseAdminClient_CreateAuthUser_EmailExists(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) _, _ = w.Write([]byte(`{"msg":"A user with this email address has already been registered"}`)) })) defer srv.Close() c := NewSupabaseAdminClient(srv.URL, "service-key") _, err := c.CreateAuthUser(context.Background(), "dup@hlc.com") if !errors.Is(err, ErrSupabaseEmailExists) { t.Fatalf("got %v, want ErrSupabaseEmailExists", err) } } // TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes — Supabase has // historically returned the link at top-level and nested under // properties. Both shapes must be accepted. func TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes(t *testing.T) { for _, tc := range []struct { name string body string want string }{ {"top-level", `{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=A"}`, "https://supabase.paliad.de/auth/v1/verify?token=A"}, {"nested", `{"properties":{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=B"}}`, "https://supabase.paliad.de/auth/v1/verify?token=B"}, } { t.Run(tc.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/auth/v1/admin/generate_link" { t.Errorf("path = %q", r.URL.Path) } body, _ := io.ReadAll(r.Body) if !strings.Contains(string(body), `"type":"recovery"`) { t.Errorf("body missing type=recovery: %s", body) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(tc.body)) })) defer srv.Close() c := NewSupabaseAdminClient(srv.URL, "service-key") got, err := c.GenerateRecoveryLink(context.Background(), "x@hlc.com") if err != nil { t.Fatalf("GenerateRecoveryLink: %v", err) } if got != tc.want { t.Errorf("link = %q, want %q", got, tc.want) } }) } } // TestSupabaseAdminClient_DeleteAuthUser pins the DELETE-by-id route shape // + 2xx happy path; the cleanup runs after a paliad.users insert failure // in AdminCreateUserFull, so the round-trip needs to work even with a // short context window. func TestSupabaseAdminClient_DeleteAuthUser(t *testing.T) { id := uuid.New() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "DELETE" { t.Errorf("method = %q", r.Method) } if r.URL.Path != "/auth/v1/admin/users/"+id.String() { t.Errorf("path = %q", r.URL.Path) } w.WriteHeader(http.StatusOK) })) defer srv.Close() c := NewSupabaseAdminClient(srv.URL, "service-key") if err := c.DeleteAuthUser(context.Background(), id); err != nil { t.Errorf("DeleteAuthUser: %v", err) } }