package page import ( "encoding/base64" "encoding/json" "errors" "math" "reflect" "strings" "testing" "time" "uuid" "github.com/stephenafamo/bob" "github.com/stephenafamo/bob/dialect/psql" pd "github.com/stephenafamo/bob/dialect/psql/dialect" "github.com/stephenafamo/bob/expr" ) func scalarSort() Sort { return Sort{ID: "scalar/v1", Columns: []Column{{Expression: expr.Quote("id"), Unique: true}}} } func TestSupportedValues(t *testing.T) { instant := time.Date(2026, 10, 27, 12, 34, 56, 123456789, time.FixedZone("odd", 3661)) values := []any{ int(0), int(math.MaxInt), int(math.MinInt), int8(math.MinInt8), int8(math.MaxInt8), int16(math.MinInt16), int16(math.MaxInt16), int32(math.MinInt32), int32(math.MaxInt32), int64(math.MinInt64), int64(math.MaxInt64), int64(9007199254740993), "", "こんにちは\x00' OR 1=1 --", instant, time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC), uuid.MustParse("01234567-89ab-cdef-0123-456789abcdef"), uuid.Nil(), } for _, value := range values { t.Run(reflect.TypeOf(value).String(), func(t *testing.T) { token, err := encodeCursor("scalar/v1", []any{value}) if err != nil { t.Fatal(err) } decoded, err := decodeCursor(token, scalarSort()) if err != nil { t.Fatal(err) } expected := value if v, ok := value.(time.Time); ok { expected = v.UTC() } if !reflect.DeepEqual(decoded, []any{expected}) { t.Fatalf("type/value: got %#v want %#v", decoded, expected) } p, err := Prepare(scalarSort(), func(v any) []any { return []any{v} }, Request{Cursor: token}, Limits{Default: 1, Maximum: 2}) if err != nil { t.Fatal(err) } q := psql.Select(Mod[*pd.SelectQuery](p)) _, args, err := bob.Build(t.Context(), q) if err != nil || !reflect.DeepEqual(args, decoded) { t.Fatalf("bound values: %#v %v", args, err) } if tm, ok := expected.(time.Time); ok { data, _ := base64.RawURLEncoding.DecodeString(token) if !strings.Contains(string(data), tm.Format(time.RFC3339Nano)) { t.Fatalf("non-UTC timestamp: %s", data) } } }) } } func TestUnsupportedValues(t *testing.T) { type named int64 n := 1 values := []any{nil, named(1), uint(1), uint64(1), float64(1), true, &n, []byte("x"), struct{}{}, "\xff", time.Date(-1, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC)} for _, value := range values { p, err := Prepare(scalarSort(), func(v any) []any { return []any{v} }, Request{}, Limits{Default: 1, Maximum: 2}) if err != nil { t.Fatal(err) } result, err := p.Page([]any{value}) var valueError *ValueError if !errors.As(err, &valueError) || valueError.Position != 0 || !reflect.DeepEqual(result, Page[any]{}) { t.Fatalf("%T: %+v %v", value, result, err) } } type alias = int64 if _, err := encodeCursor("scalar/v1", []any{alias(1)}); err != nil { t.Fatal(err) } } func TestMalformedCursors(t *testing.T) { valid := `{"v":1,"sort":"scalar/v1","values":[["int64","1"]]}` cases := []struct{ data, kind string }{ {`null`, "malformed"}, {`[]`, "malformed"}, {`{`, "malformed"}, {valid + ` {}`, "malformed"}, {strings.Replace(valid, `"v":1`, `"v":2`, 1), "version"}, {strings.Replace(valid, `"v":1`, `"v":null`, 1), "malformed"}, {strings.Replace(valid, `"v":1`, `"v":1,"v":1`, 1), "malformed"}, {strings.Replace(valid, `"v":1,`, ``, 1), "malformed"}, {strings.Replace(valid, `"v":1`, `"v":1,"columns":[]`, 1), "malformed"}, {strings.Replace(valid, `scalar/v1`, `other/v1`, 1), "sort"}, {strings.Replace(valid, `"scalar/v1"`, `null`, 1), "malformed"}, {strings.Replace(valid, `[["int64","1"]]`, `null`, 1), "malformed"}, {strings.Replace(valid, `[["int64","1"]]`, `[]`, 1), "malformed"}, {strings.Replace(valid, `[["int64","1"]]`, `[["int64","1"],["int64","2"]]`, 1), "malformed"}, } for _, entry := range []string{`null`, `{}`, `{"type":"int64","value":"1"}`, `[]`, `["int64"]`, `["int64","1","x"]`, `[null,"1"]`, `["int64",null]`, `["int64",1]`, `[1,"1"]`} { cases = append(cases, struct{ data, kind string }{strings.Replace(valid, `["int64","1"]`, entry, 1), "malformed"}) } for _, pair := range [][2]string{{"what", "1"}, {"int8", "128"}, {"int8", "-129"}, {"int16", "32768"}, {"int32", "2147483648"}, {"int64", "9223372036854775808"}, {"int", "999999999999999999999"}, {"int64", "01"}, {"int64", "+1"}, {"int64", "-0"}, {"int64", "1.0"}, {"time", "10000-01-01T00:00:00Z"}, {"time", "yesterday"}, {"time", "9999-12-31T23:59:59-01:00"}, {"time", "0000-01-01T00:00:00+01:00"}, {"uuid", "broken"}} { data, _ := json.Marshal(pair) cases = append(cases, struct{ data, kind string }{strings.Replace(valid, `["int64","1"]`, string(data), 1), "value"}) } for i, tc := range cases { _, err := decodeCursor(base64.RawURLEncoding.EncodeToString([]byte(tc.data)), scalarSort()) var ce *CursorError if !errors.As(err, &ce) || ce.Kind != tc.kind { t.Fatalf("case %d %s: %v", i, tc.data, err) } } for _, token := range []string{"!", "====", base64.RawURLEncoding.EncodeToString([]byte(valid)) + "\n", base64.RawURLEncoding.EncodeToString([]byte("\xff"))} { if _, err := decodeCursor(token, scalarSort()); err == nil { t.Fatalf("accepted %q", token) } } // A well-formed edit is a new seek boundary, not an authentication failure. edited := strings.Replace(valid, `"1"`, `"9007199254740993"`, 1) got, err := decodeCursor(base64.RawURLEncoding.EncodeToString([]byte(edited)), scalarSort()) if err != nil || got[0] != int64(9007199254740993) { t.Fatalf("edited boundary: %v %v", got, err) } } func TestCursorSize(t *testing.T) { // Find the exact final-envelope boundary instead of assuming an overhead size. var at string var length int for length = 12000; length < 12400; length++ { token, err := encodeCursor("scalar/v1", []any{strings.Repeat("x", length)}) if err == nil && len(token) == maxCursorBytes { at = token break } } if at == "" { t.Fatal("exact boundary not found") } if _, err := decodeCursor(at, scalarSort()); err != nil { t.Fatal(err) } var ce *CursorError if _, err := decodeCursor(at+"A", scalarSort()); !errors.As(err, &ce) || ce.Kind != "size" { t.Fatalf("received limit: %v", err) } p, err := Prepare(scalarSort(), func(v string) []any { return []any{v} }, Request{}, Limits{Default: 1, Maximum: 2}) if err != nil { t.Fatal(err) } result, err := p.Page([]string{strings.Repeat("x", length+1)}) if !errors.As(err, &ce) || ce.Kind != "size" || !reflect.DeepEqual(result, Page[string]{}) { t.Fatalf("emitted limit: %+v %v", result, err) } if _, err := encodeCursor("scalar/v1", []any{strings.Repeat("x", length-1)}); err != nil { t.Fatal(err) } // Failure in an overflow row also invalidates the entire page. result, err = p.Page([]string{"valid", strings.Repeat("x", length+1)}) if !errors.As(err, &ce) || !reflect.DeepEqual(result, Page[string]{}) { t.Fatalf("overflow: %+v %v", result, err) } } func TestPrepareAndKeyValidation(t *testing.T) { limits := Limits{Default: 2, Maximum: 3} badSorts := []Sort{{}, {ID: "x"}, {ID: "x", Columns: []Column{{Unique: true}}}, {ID: "x", Columns: []Column{{Expression: expr.Quote("id"), Direction: Order(2), Unique: true}}}, {ID: "x", Columns: []Column{{Expression: expr.Quote("id")}}}} key := func(v int) []any { return []any{v} } for _, sort := range badSorts { if _, err := Prepare(sort, key, Request{}, limits); err == nil { t.Fatalf("accepted sort: %+v", sort) } } if _, err := Prepare[int](scalarSort(), nil, Request{}, limits); err == nil { t.Fatal("nil key accepted") } for _, l := range []Limits{{}, {Default: 2, Maximum: 1}, {Default: 1, Maximum: math.MaxInt}} { if _, err := Prepare(scalarSort(), key, Request{}, l); err == nil { t.Fatalf("accepted limits %+v", l) } } for _, r := range []Request{{Size: -1}, {Direction: Direction(2)}} { if _, err := Prepare(scalarSort(), key, r, limits); err == nil { t.Fatal("bad request accepted") } } for _, size := range []int{0, 1, 3, 4, math.MaxInt} { p, err := Prepare(scalarSort(), key, Request{Size: size}, limits) want := min(size, 3) if size == 0 { want = 2 } if err != nil || p.size != want { t.Fatalf("size %d: %+v %v", size, p, err) } } for _, badLength := range []int{0, 2} { p, err := Prepare(scalarSort(), func(v int) []any { if v == 9 { return make([]any, badLength) } return []any{v} }, Request{Size: 1}, limits) if err != nil { t.Fatal(err) } result, err := p.Page([]int{1, 9}) var ke *KeyError if !errors.As(err, &ke) || ke.Expected != 1 || ke.Actual != badLength || !reflect.DeepEqual(result, Page[int]{}) { t.Fatalf("key: %+v %v", result, err) } } }