Files
zeekay 4679327ddc
Hanzo CI/CD / test (linux-amd64) (push) Skipped
Hanzo CI/CD / test (linux-arm64) (push) Skipped
Hanzo CI/CD / test-advisory (push) Failing after 5m15s
Hanzo CI/CD / test-amd64 (push) Failing after 17m40s
Hanzo CI/CD / cicd (push) Skipped
CI/CD / cicd (push) Failing after 17m53s
Sentinel reads the errors /v1/event lands, and takes none itself
The event plane folded every occurrence into event.fact, and the Sentry
module still read and wrote event.error. Its reads now select the error
signal of event.fact, mapping issue, env, origin and the severity band.

Errors enter through /v1/event only. The Sentry envelope and store
routes, DSN keys and their rotation leave the module; the consumer of
event.error files each fact through Ingest(org, product), which keeps
the issue lifecycle and returns the issues a batch created.
2026-09-30 01:15:34 -07:00

128 lines
5.0 KiB
Go

package implsentry
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
"github.com/hanzoai/o11y/pkg/errors"
"github.com/hanzoai/o11y/pkg/modules/organization"
"github.com/hanzoai/o11y/pkg/types"
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
"github.com/hanzoai/o11y/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// namedScope is the test seam: a plane whose names are whatever the case says.
func namedScope(org, product string) Scope {
return func(context.Context, valuer.UUID, valuer.UUID) (string, string, error) {
return org, product, nil
}
}
// orgUUID reproduces iamidentn.toUUID for an org slug. It is spelled out rather than
// imported because the point is that the two AGREE, and a check that calls the
// function under test cannot notice it changing.
func orgUUID(slug string) string {
return uuid.NewSHA1(uuid.NameSpaceURL, []byte("hanzo:o11y:org:"+slug)).String()
}
// TestTheUUIDIsDerivedFromTheSlug is the whole argument for which vocabulary wins, as
// an executable fact rather than an assertion of taste.
//
// The plane held ONE tenant under TWO spellings — 86 rows of event.error under
// cd35a51b-f7e7-5412-b67f-58b8703e5219 and 2 under `hanzo`, measured 2026-08-02. They
// are the same tenant: that uuid is uuid5(NameSpaceURL, "hanzo:o11y:org:hanzo"). So
// the slug is the primal value and the uuid a projection of it, which is why the slug
// is what the plane stores — a projection can always be recomputed from the original,
// and the original cannot be recovered from the projection.
func TestTheUUIDIsDerivedFromTheSlug(t *testing.T) {
assert.Equal(t, "cd35a51b-f7e7-5412-b67f-58b8703e5219", orgUUID("hanzo"),
"the uuid on the plane is the slug's own derivation, not a separate identity")
assert.Equal(t, "dfb7a19b-108f-5150-8131-7d207488bf48", orgUUID("admin"))
}
// TestUnnamedTenantFailsClosed: an id the plane has no name for must refuse the
// operation. Writing it under a placeholder is exactly the commingling this seam
// exists to prevent — a bucket nobody owns is a bucket several tenants share.
func TestUnnamedTenantFailsClosed(t *testing.T) {
boom := errors.Newf(errors.TypeNotFound, sentrytypes.ErrCodeSentryNotFound, "no such org")
s := &eventStore{
scope: func(context.Context, valuer.UUID, valuer.UUID) (string, string, error) { return "", "", boom },
db: defaultEventsDB,
table: defaultEventsTable,
}
// store is nil, so reaching the datastore at all would panic rather than pass.
_, err := s.Discover(context.Background(), valuer.GenerateUUID(), valuer.GenerateUUID(),
&sentrytypes.DiscoverRequest{}, testWindow())
require.Error(t, err, "a read under an unnamed tenant fails closed")
}
// TestAStoreWithoutAScopeCannotRead: the resolver is a constructor argument, but a
// zero-value store must still refuse rather than read under no name.
func TestAStoreWithoutAScopeCannotRead(t *testing.T) {
s := &eventStore{db: defaultEventsDB, table: defaultEventsTable}
_, err := s.GetEvent(context.Background(), valuer.GenerateUUID(), valuer.GenerateUUID(), "e-1")
require.Error(t, err)
}
// TestScopeResolvesFromTheRecordsThatOwnTheNames pins NewScope: the org's name comes
// from the org row and the product's from the project row, each read once.
func TestScopeResolvesFromTheRecordsThatOwnTheNames(t *testing.T) {
orgs := &countingOrgs{name: "hanzo"}
projects := &countingProjects{slug: "docs"}
scope := NewScope(orgs, projects)
orgID, projectID := valuer.GenerateUUID(), valuer.GenerateUUID()
for i := 0; i < 3; i++ {
org, product, err := scope(context.Background(), orgID, projectID)
require.NoError(t, err)
assert.Equal(t, "hanzo", org)
assert.Equal(t, "docs", product)
}
assert.Equal(t, 1, orgs.reads, "a name that cannot change is read once")
assert.Equal(t, 1, projects.reads)
}
// TestScopeRefusesAnEmptyName: a record with no name must not resolve to "".
func TestScopeRefusesAnEmptyName(t *testing.T) {
_, _, err := NewScope(&countingOrgs{name: ""}, &countingProjects{slug: "docs"})(
context.Background(), valuer.GenerateUUID(), valuer.GenerateUUID())
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "no name on the event plane"), err.Error())
}
// --- the two records the names are read from ---
//
// Each embeds the interface it stands in for and overrides ONLY the read the scope
// makes, so any other method reaching this stub is a nil-interface panic rather than
// a silent success — the seam is exactly one read per record, and the test says so.
type organizationGetterStub struct{ organization.Getter }
type projectStoreStub struct{ sentrytypes.ProjectStore }
type countingOrgs struct {
organizationGetterStub
name string
reads int
}
func (o *countingOrgs) Get(context.Context, valuer.UUID) (*types.Organization, error) {
o.reads++
return &types.Organization{Name: o.name}, nil
}
type countingProjects struct {
projectStoreStub
slug string
reads int
}
func (p *countingProjects) Get(context.Context, valuer.UUID, valuer.UUID) (*sentrytypes.Project, error) {
p.reads++
return &sentrytypes.Project{Slug: p.slug}, nil
}