Files
o11y/pkg/modules/sentry/implsentry/eventsql_test.go
zeekayandHanzo Dev 3a1d44edb8 o11y: read the event plane, and stop recreating what was dropped
The logs, traces and metrics read paths each named their own database —
o11y_logs, o11y_traces, o11y_metrics. Those were unified into one `event`
database, so every read pointed at something that no longer exists and returned
an error rather than an empty result. All three now name `event`.

Sentry's event store ran CREATE DATABASE / CREATE TABLE on init, so o11y_sentry
reappeared on the next restart no matter how many times it was dropped — a writer
that quietly resurrects whatever it names cannot be migrated, only fought. It now
reads and writes the shared plane, with a test covering the resurrect path.

These fixes existed only as working-tree state in a checkout carrying 123 dirty
files. Committed alone; the three read paths and the sentry module build clean and
implsentry passes.

NOTE, deliberately left as-is: DBName is still a compile-time constant. It is now
a CORRECT hardcode rather than a wrong one, but one name in three files is still
three places to change. Deriving it (or reading it from config) is the right shape
and is its own cut — doing it inside a fix that restores reads would hide the fix.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 18:25:39 -07:00

167 lines
6.7 KiB
Go

package implsentry
import (
"strings"
"testing"
"time"
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func testWindow() sentrytypes.Window {
return sentrytypes.Window{From: time.Unix(1000, 0), To: time.Unix(2000, 0)}
}
// TestScopeIsTenantFirst pins the single most important invariant: every read's WHERE
// prefix binds the org AND the project FIRST, then the window — so no read can omit
// the tenant boundary, and the tenant values are always bound params (never
// interpolated). org leads event.error's sort key, so the boundary is also the fast path.
func TestScopeIsTenantFirst(t *testing.T) {
where, args := scope("org-A", "proj-1", testWindow())
assert.Equal(t, "org = ? AND product = ? AND time >= ? AND time <= ?", where)
require.Len(t, args, 4)
assert.Equal(t, "org-A", args[0])
assert.Equal(t, "proj-1", args[1])
}
func TestBuildDiscover_ScopedAndBound(t *testing.T) {
req := &sentrytypes.DiscoverRequest{
GroupBy: []string{"level"},
Aggregations: []string{"count", "users"},
Filters: []sentrytypes.DiscoverFilter{{Field: "environment", Op: "eq", Value: "prod"}},
OrderBy: "count",
OrderDir: "desc",
}
sql, args, cols, err := buildDiscover("event", "error", "org-A", "proj-1", req, testWindow())
require.NoError(t, err)
// org + project are the FIRST two bound args, always.
assert.Equal(t, "org-A", args[0])
assert.Equal(t, "proj-1", args[1])
assert.Contains(t, sql, "org = ? AND product = ?")
// The filter value is bound, never inlined.
assert.Contains(t, sql, "environment = ?")
assert.Contains(t, args, "prod")
// Fixed aggregation expressions, aliased by key.
assert.Contains(t, sql, "count() AS count")
assert.Contains(t, sql, "count(DISTINCT person_id) AS users")
assert.Contains(t, sql, "GROUP BY level")
assert.Contains(t, sql, "ORDER BY count DESC")
assert.Equal(t, []discoverCol{{"level", kindString}, {"count", kindUint}, {"users", kindUint}}, cols)
}
// TestBuildDiscover_RejectsInjection proves no client string reaches the SQL as an
// identifier: any field/aggregation/orderBy outside the allowlist is an error, never
// interpolated.
func TestBuildDiscover_RejectsInjection(t *testing.T) {
injection := "message) FROM event.error; DROP TABLE error --"
cases := []struct {
name string
req *sentrytypes.DiscoverRequest
}{
{"groupBy", &sentrytypes.DiscoverRequest{GroupBy: []string{injection}}},
{"aggregation", &sentrytypes.DiscoverRequest{Aggregations: []string{injection}}},
{"filter field", &sentrytypes.DiscoverRequest{Filters: []sentrytypes.DiscoverFilter{{Field: injection, Value: "x"}}}},
{"filter op", &sentrytypes.DiscoverRequest{Filters: []sentrytypes.DiscoverFilter{{Field: "level", Op: "; DROP", Value: "x"}}}},
{"orderBy", &sentrytypes.DiscoverRequest{GroupBy: []string{"level"}, OrderBy: injection}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sql, args, _, err := buildDiscover("db", "t", "org", "proj", c.req, testWindow())
require.Error(t, err, "malicious %s must be rejected, not built", c.name)
// The injection never becomes SQL: the builder returns an error and NO
// statement/args. (The error message may name the rejected field for the
// operator — that string is never executed.)
assert.Empty(t, sql)
assert.Empty(t, args)
})
}
}
// TestBuildDiscover_MaliciousFilterValueIsBound confirms a value that LOOKS like SQL is
// carried as a bound arg, not spliced into the statement.
func TestBuildDiscover_MaliciousFilterValueIsBound(t *testing.T) {
evil := "'; DROP TABLE error; --"
req := &sentrytypes.DiscoverRequest{
GroupBy: []string{"level"},
Filters: []sentrytypes.DiscoverFilter{{Field: "release", Op: "eq", Value: evil}},
}
sql, args, _, err := buildDiscover("db", "t", "org", "proj", req, testWindow())
require.NoError(t, err)
assert.NotContains(t, sql, "DROP TABLE", "value must not appear in the SQL text")
assert.Contains(t, args, evil, "value must be a bound arg")
}
func TestBuildStats_ScopedAndFieldAllowlist(t *testing.T) {
sql, args, err := buildStats("db", "t", "org-A", "proj-1", "errors", testWindow())
require.NoError(t, err)
assert.Equal(t, "org-A", args[0])
assert.Equal(t, "proj-1", args[1])
assert.Contains(t, sql, "org = ? AND product = ?")
assert.Contains(t, sql, "level IN ('error','fatal')")
_, _, err = buildStats("db", "t", "org", "proj", "sneaky') OR 1=1 --", testWindow())
require.Error(t, err, "unknown stats field must be rejected")
}
func TestRowReads_AreOrgAndProjectScoped(t *testing.T) {
// Event detail is org+project bound (a project is an isolation unit).
get, gArgs := buildGetEvent("db", "t", "org-A", "proj-1", "evt-1")
assert.Contains(t, get, "org = ? AND product = ? AND id = ?")
assert.Equal(t, "org-A", gArgs[0])
assert.Equal(t, "proj-1", gArgs[1])
// Issue occurrences are org+project bound.
fp, fArgs := buildListForFingerprint("db", "t", "org-A", "proj-1", "fp-1", 10)
assert.Contains(t, fp, "org = ? AND product = ? AND `group` = ?")
assert.Equal(t, "org-A", fArgs[0])
assert.Equal(t, "proj-1", fArgs[1])
// Trace detail reads the errors on the trace (org+project+trace bound), not spans.
ft, ftArgs := buildListForTrace("db", "t", "org-A", "proj-1", "trace-xyz", 10)
assert.Contains(t, ft, "org = ? AND product = ? AND trace_id = ?")
assert.Equal(t, "org-A", ftArgs[0])
assert.Equal(t, "proj-1", ftArgs[1])
assert.Equal(t, "trace-xyz", ftArgs[2])
logs, lArgs := buildListLogs("db", "t", "org-A", "proj-1", "boom", testWindow(), 10)
assert.Contains(t, logs, "org = ? AND product = ?")
assert.Equal(t, "org-A", lArgs[0])
assert.Equal(t, "proj-1", lArgs[1])
assert.Contains(t, logs, "message LIKE ? OR class LIKE ?")
tr, tArgs := buildListTraces("db", "t", "org-A", "proj-1", testWindow(), 10)
assert.Contains(t, tr, "org = ? AND product = ?")
assert.Equal(t, "org-A", tArgs[0])
df, dArgs := buildDistinctFingerprints("db", "t", "org-A", "proj-1", testWindow())
assert.Contains(t, df, "org = ? AND product = ?")
assert.Equal(t, "org-A", dArgs[0])
}
func TestResolveWindow(t *testing.T) {
now := time.Unix(1_000_000, 0)
w := resolveWindow("7d", now)
assert.Equal(t, now, w.To)
assert.Equal(t, now.Add(-7*24*time.Hour), w.From)
// Unknown period falls back to the default (24h), never an unbounded scan.
def := resolveWindow("garbage", now)
assert.Equal(t, now.Add(-defaultPeriod), def.From)
}
func TestClampLimit(t *testing.T) {
assert.Equal(t, 100, clampLimit(0, 100, 1000))
assert.Equal(t, 1000, clampLimit(5000, 100, 1000))
assert.Equal(t, 42, clampLimit(42, 100, 1000))
}
func TestBuildDiscover_TooManyGroupBy(t *testing.T) {
req := &sentrytypes.DiscoverRequest{GroupBy: strings.Split("a,b,c,d,e,f,g", ",")}
_, _, _, err := buildDiscover("db", "t", "org", "proj", req, testWindow())
require.Error(t, err)
}