Files
zooqueenandHanzo Dev e80fa26967 sentry: keep a disabled project closed, and test the gate that exists
ResolveIngest was rewritten to attribute ingest by the org's publishable key —
the same key /v1/event takes — instead of a per-project DSN watermark. Two
things did not come along.

The project's own status stopped being consulted. Disabling a project is how a
tenant stops ingest without rotating an org-wide key, and ProjectStatus says as
much where it is declared, so an existing row that is not active now refuses.
Only an EXISTING row can refuse: a project first seen on this ingest is
provisioned active a line later, and a store that errors must not be read as
permission.

TestResolveIngest_FailsClosed still built per-project versioned keys and
rotated watermarks, none of which the code has any more — that was the red.
It now covers the gate as written: no resolver installed, a key the resolver
rejects, an empty key, and a resolver that answers ok while naming no org, all
closed; a good key resolves to the org it names and provisions the project.

TestResolveIngest_DisabledProjectFailsClosed was GREEN and proved nothing —
with no resolver installed every call returns false, so its assertion held for
the wrong reason. It installs one now, and checked both ways: it fails without
the status check and passes with it.

Full suite green (`go build ./...`, `go test -count=1 -skip '^TestEmailRejected$' ./...`).

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 23:24:07 -07:00

416 lines
17 KiB
Go

package implsentry
import (
"context"
"testing"
"time"
"github.com/hanzoai/o11y/pkg/identn/iamidentn"
"github.com/hanzoai/o11y/pkg/modules/errortracking"
"github.com/hanzoai/o11y/pkg/modules/errortracking/implerrortracking"
"github.com/hanzoai/o11y/pkg/modules/sentry"
"github.com/hanzoai/o11y/pkg/sqlstore"
"github.com/hanzoai/o11y/pkg/types/errortrackingtypes"
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
"github.com/hanzoai/o11y/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeEvents is an in-memory EventStore: it records every write keyed by (org,
// project) so a test can assert BOTH that ingest reached the events plane AND that a
// read is only ever asked for the caller's own tenant.
type fakeEvents struct {
inserts map[[2]string][]*sentrytypes.Event
lastOrg string
lastProj string
discovers int
}
func newFakeEvents() *fakeEvents {
return &fakeEvents{inserts: map[[2]string][]*sentrytypes.Event{}}
}
func (f *fakeEvents) key(o, p valuer.UUID) [2]string { return [2]string{o.String(), p.String()} }
func (f *fakeEvents) Insert(_ context.Context, o, p valuer.UUID, e []*sentrytypes.Event) error {
f.inserts[f.key(o, p)] = append(f.inserts[f.key(o, p)], e...)
return nil
}
func (f *fakeEvents) Discover(_ context.Context, o, p valuer.UUID, _ *sentrytypes.DiscoverRequest, _ sentrytypes.Window) (*sentrytypes.DiscoverResult, error) {
f.lastOrg, f.lastProj, f.discovers = o.String(), p.String(), f.discovers+1
return &sentrytypes.DiscoverResult{}, nil
}
func (f *fakeEvents) GetEvent(_ context.Context, o, p valuer.UUID, id string) (*sentrytypes.Event, error) {
// Tenant boundary: only THIS (org, project)'s events — never another org's or
// another project's within the org.
for _, e := range f.inserts[f.key(o, p)] {
if e.EventID == id {
return e, nil
}
}
return nil, nil
}
func (f *fakeEvents) ListForFingerprint(_ context.Context, o, p valuer.UUID, fp string, _ int) ([]*sentrytypes.Event, error) {
var out []*sentrytypes.Event
for _, e := range f.inserts[f.key(o, p)] {
if e.Fingerprint == fp {
out = append(out, e)
}
}
return out, nil
}
func (f *fakeEvents) ListForTrace(_ context.Context, o, p valuer.UUID, traceID string, _ int) ([]*sentrytypes.Event, error) {
f.lastOrg, f.lastProj = o.String(), p.String()
var out []*sentrytypes.Event
for _, e := range f.inserts[f.key(o, p)] {
if e.TraceID == traceID {
out = append(out, e)
}
}
return out, nil
}
func (f *fakeEvents) DistinctFingerprints(_ context.Context, o, p valuer.UUID, _ sentrytypes.Window) ([]string, error) {
seen := map[string]bool{}
var out []string
for _, e := range f.inserts[f.key(o, p)] {
if !seen[e.Fingerprint] {
seen[e.Fingerprint] = true
out = append(out, e.Fingerprint)
}
}
return out, nil
}
func (f *fakeEvents) ListLogs(_ context.Context, o, p valuer.UUID, _ string, _ sentrytypes.Window, _ int) ([]*sentrytypes.Event, error) {
f.lastOrg, f.lastProj = o.String(), p.String()
return f.inserts[f.key(o, p)], nil
}
func (f *fakeEvents) ListTraces(_ context.Context, o, p valuer.UUID, _ sentrytypes.Window, _ int) ([]*sentrytypes.TraceSummary, error) {
f.lastOrg, f.lastProj = o.String(), p.String()
return nil, nil
}
func (f *fakeEvents) Stats(_ context.Context, o, p valuer.UUID, _ string, _ sentrytypes.Window) ([]sentrytypes.StatsPoint, error) {
f.lastOrg, f.lastProj = o.String(), p.String()
return nil, nil
}
const testSecret = "platform-ingest-secret"
type harness struct {
mod sentry.Module
events *fakeEvents
projects sentrytypes.ProjectStore
}
func newModuleHarness(t *testing.T) *harness {
t.Helper()
store := newModuleSQLStore(t)
projects := NewProjectStore(store)
issues := errortracking.Module(implerrortracking.NewModule(implerrortracking.NewStore(store), implerrortracking.NewNoopSink()))
events := newFakeEvents()
mod := NewModule(projects, events, issues, Config{IngestSecret: []byte(testSecret), Host: "api.hanzo.ai"})
return &harness{mod: mod, events: events, projects: projects}
}
// newModuleSQLStore is a sqlite store with BOTH the projects table and the
// errortracking o11y_issues lifecycle table (+ its unique index).
func newModuleSQLStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
store := newTestSQLStore(t) // creates o11y_sentry_projects (+ unique index)
_, err := store.BunDB().NewCreateTable().Model((*errortrackingtypes.Issue)(nil)).IfNotExists().Exec(context.Background())
require.NoError(t, err)
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_o11y_issues_org_fingerprint ON o11y_issues (org_id, fingerprint)`)
require.NoError(t, err)
return store
}
func mustProject(t *testing.T, h *harness, org valuer.UUID, name string) *sentrytypes.GettableProject {
t.Helper()
p, err := h.mod.CreateProject(context.Background(), org, &sentrytypes.PostableProject{Name: name})
require.NoError(t, err)
return p
}
func occ(fp, eventID string) *errortrackingtypes.Occurrence {
return occTrace(fp, eventID, "")
}
func occTrace(fp, eventID, traceID string) *errortrackingtypes.Occurrence {
return &errortrackingtypes.Occurrence{
EventID: eventID, Fingerprint: fp, Type: "Error", Value: "boom",
Level: "error", Timestamp: time.Now().UTC(), TraceID: traceID,
}
}
// TestIngest_WritesEventsAndIssues proves the dual write: one Ingest lands the
// occurrence on BOTH the columnar events plane and the grouped-issue lifecycle.
func TestIngest_WritesEventsAndIssues(t *testing.T) {
ctx := context.Background()
h := newModuleHarness(t)
org := valuer.GenerateUUID()
proj := mustProject(t, h, org, "web")
pid := proj.Project.ID
require.NoError(t, h.mod.Ingest(ctx, org, pid, []*errortrackingtypes.Occurrence{occ("fp-1", "e1"), occ("fp-1", "e2")}))
// Events plane got both occurrences under (org, project).
assert.Len(t, h.events.inserts[[2]string{org.String(), pid.String()}], 2)
// Issue lifecycle grouped them into one issue for the org.
issues, err := h.mod.ListIssues(ctx, org, nil, &errortrackingtypes.IssuesQuery{}, testWindow())
require.NoError(t, err)
require.Len(t, issues.Items, 1)
assert.Equal(t, "fp-1", issues.Items[0].Fingerprint)
assert.Equal(t, int64(2), issues.Items[0].Count)
}
// withIngestKeyResolver installs a resolver for one test and puts back whatever
// was there. The resolver is package state read per request, so a test that left
// its own behind would decide the answer for every test after it.
func withIngestKeyResolver(t *testing.T, fn IngestKeyResolver) {
t.Helper()
prev := getIngestKeyResolver()
SetIngestKeyResolver(fn)
t.Cleanup(func() { SetIngestKeyResolver(prev) })
}
const testOrgSlug = "acme"
// resolverFor answers only for key, and names no org for anything else — a stand-in
// for the host asking IAM whether a publishable key belongs to somebody.
func resolverFor(key string) IngestKeyResolver {
return func(_ context.Context, presented string) (string, bool) {
if presented == key {
return testOrgSlug, true
}
return "", false
}
}
// TestResolveIngest_FailsClosed is the ingest gate. Attribution is the org's
// publishable key — the same one /v1/event takes — resolved by the host, so what
// has to hold is that every way of not presenting a good one stays shut: no
// resolver installed at all, a key the resolver rejects, an empty key, and a
// resolver that answers ok with no org. Only a key naming an org resolves, and the
// project in the DSN is born under that org on first sight.
func TestResolveIngest_FailsClosed(t *testing.T) {
ctx := context.Background()
h := newModuleHarness(t)
pid := valuer.GenerateUUID()
const goodKey = "pk-live-good"
// The default. A host that never installed a resolver ingests nothing.
_, ok := h.mod.ResolveIngest(ctx, pid, goodKey)
assert.False(t, ok, "no resolver installed must not resolve")
withIngestKeyResolver(t, resolverFor(goodKey))
gotOrg, ok := h.mod.ResolveIngest(ctx, pid, goodKey)
require.True(t, ok)
assert.Equal(t, iamidentn.OrgUUID(testOrgSlug), gotOrg, "resolves to the org the key names")
// The DSN's project is provisioned under that org, so nobody had to create it.
_, _, status, found, err := h.projects.Resolve(ctx, pid)
require.NoError(t, err)
require.True(t, found, "first keyed ingest provisions the project")
assert.Equal(t, sentrytypes.ProjectActive, status)
// A key the resolver does not recognise.
_, ok = h.mod.ResolveIngest(ctx, pid, "pk-live-someone-else")
assert.False(t, ok)
// No key at all.
_, ok = h.mod.ResolveIngest(ctx, pid, "")
assert.False(t, ok)
// A resolver that says yes but names no org is not an authorisation.
withIngestKeyResolver(t, func(context.Context, string) (string, bool) { return "", true })
_, ok = h.mod.ResolveIngest(ctx, pid, goodKey)
assert.False(t, ok, "ok with an empty org slug must not resolve")
}
// TestResolveIngest_DisabledProjectFailsClosed covers the other half: the key is
// good and the org is real, and the project still refuses because it was disabled.
// Turning a project off is how ingest is stopped without rotating an org's key.
func TestResolveIngest_DisabledProjectFailsClosed(t *testing.T) {
ctx := context.Background()
h := newModuleHarness(t)
org := iamidentn.OrgUUID(testOrgSlug)
proj := mustProject(t, h, org, "web")
pid := proj.Project.ID
const goodKey = "pk-live-good"
withIngestKeyResolver(t, resolverFor(goodKey))
// Active first, so the disabled result below is the disabling and nothing else.
_, ok := h.mod.ResolveIngest(ctx, pid, goodKey)
require.True(t, ok, "an active project resolves with a good key")
disableProject(t, h, org, pid)
_, ok = h.mod.ResolveIngest(ctx, pid, goodKey)
assert.False(t, ok, "a disabled project must not resolve even with a good key")
}
// TestReads_ForeignProjectDenied is the mandatory read isolation: a project id that
// belongs to another org is rejected (never silently scoped to the caller), so no
// cross-tenant read is possible via a client-supplied project.
func TestReads_ForeignProjectDenied(t *testing.T) {
ctx := context.Background()
h := newModuleHarness(t)
orgA, orgB := valuer.GenerateUUID(), valuer.GenerateUUID()
projA := mustProject(t, h, orgA, "a").Project.ID
_ = mustProject(t, h, orgB, "b")
// org B asks to Discover org A's project -> denied (project not found in B's org).
_, err := h.mod.Discover(ctx, orgB, &sentrytypes.DiscoverRequest{Project: projA.String()})
require.Error(t, err)
// Same for logs / traces / stats / trace-detail — every project-scoped read.
_, err = h.mod.ListLogs(ctx, orgB, projA, "", "24h", 10)
require.Error(t, err)
_, err = h.mod.ListTraces(ctx, orgB, projA, "24h", 10)
require.Error(t, err)
_, err = h.mod.Stats(ctx, orgB, projA, "events", "24h")
require.Error(t, err)
_, err = h.mod.TraceDetail(ctx, orgB, projA, "trace-1")
require.Error(t, err)
// The fake events store was NEVER asked for org A's data on B's behalf.
assert.NotEqual(t, orgA.String(), h.events.lastOrg)
}
// TestListIssues_ProjectFilterViaEventsPlane proves the org-grouped issue list is
// correctly projected to a single project through the events-plane fingerprints, and
// that a project with no captured errors yields zero issues (never the whole org).
func TestListIssues_ProjectFilterViaEventsPlane(t *testing.T) {
ctx := context.Background()
h := newModuleHarness(t)
org := valuer.GenerateUUID()
web := mustProject(t, h, org, "web").Project.ID
api := mustProject(t, h, org, "api").Project.ID
require.NoError(t, h.mod.Ingest(ctx, org, web, []*errortrackingtypes.Occurrence{occ("fp-web", "e1")}))
require.NoError(t, h.mod.Ingest(ctx, org, api, []*errortrackingtypes.Occurrence{occ("fp-api", "e2")}))
// Whole-org list sees BOTH issues.
all, err := h.mod.ListIssues(ctx, org, nil, &errortrackingtypes.IssuesQuery{}, testWindow())
require.NoError(t, err)
assert.Len(t, all.Items, 2)
// Project-scoped list sees only that project's issue.
webOnly, err := h.mod.ListIssues(ctx, org, &web, &errortrackingtypes.IssuesQuery{}, testWindow())
require.NoError(t, err)
require.Len(t, webOnly.Items, 1)
assert.Equal(t, "fp-web", webOnly.Items[0].Fingerprint)
// A foreign project on the issue list is denied.
otherOrg := valuer.GenerateUUID()
foreign := mustProject(t, h, otherOrg, "x").Project.ID
_, err = h.mod.ListIssues(ctx, org, &foreign, &errortrackingtypes.IssuesQuery{}, testWindow())
require.Error(t, err)
}
// TestTraceDetail_CrossTenantTraceIsolation is the exact scenario Red flagged: org B
// injects an event carrying org A's trace_id, then reads that trace. Because the
// load-bearing scope is on the events READ (org AND project bound), TraceDetail returns
// ONLY org B's own project events for the trace — ZERO of org A's data — and the
// event.span plane is never read here — this returns the errors on the trace.
func TestTraceDetail_CrossTenantTraceIsolation(t *testing.T) {
ctx := context.Background()
h := newModuleHarness(t)
orgA, orgB := valuer.GenerateUUID(), valuer.GenerateUUID()
pA := mustProject(t, h, orgA, "a").Project.ID
pB := mustProject(t, h, orgB, "b").Project.ID
const victimTrace = "VICTIM-TRACE-DEADBEEF"
// org A's real event on the victim trace.
require.NoError(t, h.mod.Ingest(ctx, orgA, pA, []*errortrackingtypes.Occurrence{occTrace("fp-a", "a-secret", victimTrace)}))
// org B forges an event CLAIMING the same trace id in ITS OWN project.
require.NoError(t, h.mod.Ingest(ctx, orgB, pB, []*errortrackingtypes.Occurrence{occTrace("fp-b", "b-own", victimTrace)}))
// org B reads the trace in its own project: sees ONLY its own event, never org A's.
detail, err := h.mod.TraceDetail(ctx, orgB, pB, victimTrace)
require.NoError(t, err)
events := detail.(map[string]any)["events"].([]*sentrytypes.Event)
require.Len(t, events, 1)
assert.Equal(t, "b-own", events[0].EventID)
for _, e := range events {
assert.NotEqual(t, "a-secret", e.EventID, "org A's event must NEVER surface for org B")
assert.Equal(t, orgB.String(), e.OrgID, "only org B rows may be returned")
}
// org B cannot even target org A's project (foreign project → denied).
_, err = h.mod.TraceDetail(ctx, orgB, pA, victimTrace)
require.Error(t, err)
}
// TestGetEvent_ProjectScoped: a within-tenant cross-PROJECT read is denied — a project
// is the isolation unit, so an event in project X is not readable via project Y.
func TestGetEvent_ProjectScoped(t *testing.T) {
ctx := context.Background()
h := newModuleHarness(t)
org := valuer.GenerateUUID()
web := mustProject(t, h, org, "web").Project.ID
api := mustProject(t, h, org, "api").Project.ID
require.NoError(t, h.mod.Ingest(ctx, org, web, []*errortrackingtypes.Occurrence{occ("fp", "evt-web")}))
// Correct project → found.
got, err := h.mod.GetEvent(ctx, org, web, "evt-web")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "evt-web", got.EventID)
// Wrong project (same org) → not found (not a leak).
got, err = h.mod.GetEvent(ctx, org, api, "evt-web")
require.NoError(t, err)
assert.Nil(t, got, "event from another project must not be readable via a different project")
// Foreign project (another org) → denied.
other := valuer.GenerateUUID()
foreign := mustProject(t, h, other, "x").Project.ID
_, err = h.mod.GetEvent(ctx, org, foreign, "evt-web")
require.Error(t, err)
}
// TestIssueEvents_ProjectScoped: issue occurrences are read only for the named project.
func TestIssueEvents_ProjectScoped(t *testing.T) {
ctx := context.Background()
h := newModuleHarness(t)
org := valuer.GenerateUUID()
web := mustProject(t, h, org, "web").Project.ID
api := mustProject(t, h, org, "api").Project.ID
require.NoError(t, h.mod.Ingest(ctx, org, web, []*errortrackingtypes.Occurrence{occ("fp-shared", "e-web")}))
require.NoError(t, h.mod.Ingest(ctx, org, api, []*errortrackingtypes.Occurrence{occ("fp-shared", "e-api")}))
// One org-scoped issue exists for fp-shared; find it.
issues, err := h.mod.ListIssues(ctx, org, nil, &errortrackingtypes.IssuesQuery{}, testWindow())
require.NoError(t, err)
require.Len(t, issues.Items, 1)
issueID := issues.Items[0].ID
// Occurrences scoped to web → only the web event.
webEvents, err := h.mod.IssueEvents(ctx, org, issueID, web, 0)
require.NoError(t, err)
require.Len(t, webEvents, 1)
assert.Equal(t, "e-web", webEvents[0].EventID)
// Foreign project → denied.
other := valuer.GenerateUUID()
foreign := mustProject(t, h, other, "x").Project.ID
_, err = h.mod.IssueEvents(ctx, org, issueID, foreign, 0)
require.Error(t, err)
}
// disableProject flips a project's status to disabled via a direct store write.
func disableProject(t *testing.T, h *harness, org, id valuer.UUID) {
t.Helper()
ps := h.projects.(*projectStore)
_, err := ps.sqlstore.BunDB().NewUpdate().
Model((*sentrytypes.Project)(nil)).
Set("status = ?", sentrytypes.ProjectDisabled).
Where("org_id = ?", org).Where("id = ?", id).
Exec(context.Background())
require.NoError(t, err)
}