package lagoon import ( "context" "fmt" "reflect" "strings" "testing" "gorm.io/gorm" ) func TestOrderClauseAllowList(t *testing.T) { allowed := []string{"acme_blog_posts.title", "items.title"} got, err := orderClause("acme_blog_posts.title", "asc", allowed, orderOptions{}) if err != nil { t.Fatal(err) } if got != "acme_blog_posts.title ASC" { t.Fatalf("got %q", got) } if strings.Contains(strings.ToLower(got), "collate") { t.Fatalf("must not emit COLLATE: %q", got) } got, err = orderClause("items.title", "DESC", allowed, orderOptions{}) if err != nil { t.Fatal(err) } if got != "items.title DESC" { t.Fatalf("got %q", got) } if strings.Contains(strings.ToLower(got), "collate") { t.Fatalf("must not emit COLLATE: %q", got) } if _, err := orderClause("acme_blog_posts.title;drop table x", "asc", allowed, orderOptions{}); err == nil { t.Fatal("want reject unknown column") } if _, err := orderClause("acme_blog_posts.title", "ascending", allowed, orderOptions{}); err == nil { t.Fatal("want reject unknown direction") } if _, err := OrderBy(nil, "items.title", "asc", allowed); err == nil { t.Fatal("want nil db error") } if _, err := orderClause("items.title", "asc", nil, orderOptions{}); err == nil { t.Fatal("empty allow-list must reject") } } func TestOrderClauseCollation(t *testing.T) { allowed := []string{"items.title"} opts := resolveOrderOptions([]OrderOption{Collate("pl-x-icu")}) got, err := orderClause("items.title", "asc", allowed, opts) if err != nil { t.Fatal(err) } if got != `items.title COLLATE "pl-x-icu" ASC` { t.Fatalf("got %q", got) } got, err = orderClause("items.title", "DESC", allowed, opts) if err != nil { t.Fatal(err) } if got != `items.title COLLATE "pl-x-icu" DESC` { t.Fatalf("got %q", got) } } func TestCollateAcceptsValidNames(t *testing.T) { allowed := []string{"items.title"} for _, name := range []string{ "pl-x-icu", "und-x-icu", "en-US-x-icu", "C", "POSIX", "ucs_basic", "default", "sr_RS.utf8@latin", strings.Repeat("a", 63), } { got, err := orderClause("items.title", "asc", allowed, resolveOrderOptions([]OrderOption{Collate(name)})) if err != nil { t.Fatalf("%q: %v", name, err) } want := `items.title COLLATE "` + name + `" ASC` if got != want { t.Fatalf("%q: got %q want %q", name, got, want) } } } func TestCollateRejectsInvalidNames(t *testing.T) { allowed := []string{"items.title"} db := &gorm.DB{} for _, name := range []string{ "", strings.Repeat("a", 64), "pl x icu", `pl-x-icu"`, `pl-x-icu" ASC, (SELECT 1) --`, "pl;DROP TABLE x", `pl\x-icu`, "pl\x00x-icu", "pł-x-icu", "-pl-x-icu", ".pl-x-icu", } { want := fmt.Sprintf("lagoon: order collation %q is not a valid collation name", name) _, err := orderClause("items.title", "asc", allowed, resolveOrderOptions([]OrderOption{Collate(name)})) if err == nil || err.Error() != want { t.Fatalf("%q: got %v want %s", name, err, want) } q, err := OrderBy(db, "items.title", "asc", allowed, Collate(name)) if q != nil || err == nil || err.Error() != want { t.Fatalf("OrderBy %q: got (%v, %v)", name, q, err) } } if db.Statement != nil { t.Fatal("rejected collation must not touch the GORM handle") } } func TestCollateOptionOrdering(t *testing.T) { allowed := []string{"items.title"} got, err := orderClause("items.title", "asc", allowed, resolveOrderOptions([]OrderOption{Collate("C"), Collate("pl-x-icu")})) if err != nil { t.Fatal(err) } if got != `items.title COLLATE "pl-x-icu" ASC` { t.Fatalf("last Collate must win: %q", got) } got, err = orderClause("items.title", "asc", allowed, resolveOrderOptions(nil)) if err != nil { t.Fatal(err) } if got != "items.title ASC" { t.Fatalf("no options must keep the plain clause: %q", got) } // Column and direction are checked before the collation. bad := resolveOrderOptions([]OrderOption{Collate(`bad"name`)}) _, err = orderClause("items.other", "asc", allowed, bad) if err == nil || !strings.Contains(err.Error(), "order column") { t.Fatalf("column check must run first: %v", err) } _, err = orderClause("items.title", "sideways", allowed, bad) if err == nil || !strings.Contains(err.Error(), "order direction") { t.Fatalf("direction check must run second: %v", err) } } func TestCollatePolishOrderOnLibcDatabase(t *testing.T) { admin := lagoonDB(t) ctx := t.Context() const name = "lagoon_collate_libc" if _, err := admin.ExecContext(ctx, `CREATE DATABASE `+name+` TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER libc LOCALE 'C'`); err != nil && !strings.Contains(err.Error(), "already exists") { t.Fatalf("create libc database: %v", err) } dsn, err := dsnWithDB(lagoonDSN, name) if err != nil { t.Fatal(err) } sqlDB, gdb, err := Open(ctx, dsn) if err != nil { t.Fatalf("Open must accept a libc 'C' database: %v", err) } t.Cleanup(func() { _ = sqlDB.Close() _, _ = admin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+name+` WITH (FORCE)`) }) if _, err := Use(ctx, sqlDB); err != nil { t.Fatalf("Use must accept a libc 'C' database: %v", err) } if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE words (name text)`); err != nil { t.Fatal(err) } if _, err := sqlDB.ExecContext(ctx, `INSERT INTO words (name) VALUES ('Zebra'), ('Łoś'), ('Lis'), ('Mysz')`); err != nil { t.Fatal(err) } allowed := []string{"words.name"} scan := func(opts ...OrderOption) ([]string, error) { q, err := OrderBy(gdb.Table("words"), "words.name", "asc", allowed, opts...) if err != nil { return nil, err } var names []string if err := q.Pluck("name", &names).Error; err != nil { return nil, err } return names, nil } plain, err := scan() if err != nil { t.Fatal(err) } if want := []string{"Lis", "Mysz", "Zebra", "Łoś"}; !reflect.DeepEqual(plain, want) { t.Fatalf("plain order on libc 'C': got %v want %v", plain, want) } polish, err := scan(Collate("pl-x-icu")) if err != nil { t.Fatal(err) } if want := []string{"Lis", "Łoś", "Mysz", "Zebra"}; !reflect.DeepEqual(polish, want) { t.Fatalf("pl-x-icu order: got %v want %v", polish, want) } _, err = scan(Collate("no-such-collation-x")) if err == nil || !strings.Contains(err.Error(), "no-such-collation-x") { t.Fatalf("unknown collation must reach Postgres as an identifier: %v", err) } }