mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
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]>
167 lines
6.7 KiB
Go
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)
|
|
}
|