feat: reject a contact timeline cursor whose source rank names no merged table with a 400 in the handler through ContactTimelineSource.Valid, since rank zero is reserved for the legacy before bound and a rank past the last source would re-admit the events at the cursor's instant

This commit is contained in:
Matthew Meszaros
2026-09-03 20:21:42 -07:00
parent 83b0a5ede3
commit 0981635c3a
3 changed files with 28 additions and 0 deletions
+4
View File
@@ -180,6 +180,10 @@ func (h *Handler) ListContactTimeline(c *gin.Context) {
errx.Handle(c, xerr)
return
}
if !models.ContactTimelineSource(source).Valid() {
errx.Handle(c, errx.New(errx.BadRequest, "invalid cursor"))
return
}
cursor = &models.ContactTimelineKey{At: at, Source: models.ContactTimelineSource(source), ID: id}
} else if raw := c.Query("before"); raw != "" {
t, err := time.Parse(time.RFC3339Nano, raw)
+8
View File
@@ -440,6 +440,14 @@ const (
TimelineSourcePageHit ContactTimelineSource = 14 // website_page_hits
)
// Valid reports whether s names a source the timeline is merged from. A
// cursor carrying any other rank is malformed: zero is reserved for the
// legacy bare-timestamp bound and anything above the last source would
// re-admit the events at the cursor's instant.
func (s ContactTimelineSource) Valid() bool {
return s >= TimelineSourceProgressSent && s <= TimelineSourcePageHit
}
// ContactTimelineKey is one event's position in the merged feed. A page
// resumes strictly after the key of the last event it returned, comparing
// (At, Source, ID) as a tuple, which is what the opaque cursor carries.
+16
View File
@@ -0,0 +1,16 @@
package models
import "testing"
func TestContactTimelineSourceValid(t *testing.T) {
for s := TimelineSourceProgressSent; s <= TimelineSourcePageHit; s++ {
if !s.Valid() {
t.Fatalf("source %d is one the feed merges and must be valid", s)
}
}
for _, s := range []ContactTimelineSource{0, -1, TimelineSourcePageHit + 1, 99} {
if s.Valid() {
t.Fatalf("source %d names no table and must be rejected in a cursor", s)
}
}
}