Files
o11y/pkg/modules/sentry/implsentry/handler_test.go
zeekay c10b49bbe8 feat(o11y): nothing answers outside /v1/o11y — sentinel folds, /ws merges
Two addresses were still spelled at a root that is not this capability's, and
HIP-0139 §3 gives every route one home: /v1/<name>. Both close by fold, and
neither leaves an alias behind.

/v1/sentinel/* -> /v1/o11y/sentinel/*. The error-tracking face is twelve
concrete paths the runtime registers literally, plus the declaration table's
group over the same root, so one constant and the registrations under it move
together. It is one capability's second face, not a second capability: same
store, same runtime, same tenant rule. The ingest door is unaffected — a minted
DSN names /v1/event and always did.

/ws/query_progress merges onto /v1/o11y/query_progress. It was never a second
read: one handler answered both mount points, upgrading when the caller asked
and long-polling when it did not. The Upgrade is a property of the request, not
of the address, so two paths bought a second spelling and nothing else. /ws was
also the last address this service answered outside /v1, and the console's
api-prefix list drops it with the route.

The hatch census goes eleven to ten and the surface 367 to 366 — the numbers are
tests here, not prose, so they move in the same commit as the route.
2026-08-20 20:32:28 -07:00

108 lines
4.5 KiB
Go

package implsentry
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/o11y/pkg/http/routing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/hanzoai/o11y/pkg/modules/sentry"
"github.com/hanzoai/o11y/pkg/types/authtypes"
"github.com/hanzoai/o11y/pkg/types/sentrytypes"
"github.com/hanzoai/o11y/pkg/valuer"
)
// stubSentry answers only the calls under test; the embedded interface carries
// the rest, so this fake says exactly which method the route reaches.
type stubSentry struct {
sentry.Module
gotEventID string
gotEventOrg valuer.UUID
gotProject valuer.UUID
gotIngestProj valuer.UUID
}
func (s *stubSentry) GetEvent(_ context.Context, orgID, projectID valuer.UUID, eventID string) (*sentrytypes.Event, error) {
s.gotEventOrg, s.gotProject, s.gotEventID = orgID, projectID, eventID
return &sentrytypes.Event{}, nil
}
// ResolveIngest fails closed, which is what an unknown key does in production —
// the read under test happens before it.
func (s *stubSentry) ResolveIngest(_ context.Context, projectID valuer.UUID, _ string) (valuer.UUID, bool) {
s.gotIngestProj = projectID
return valuer.UUID{}, false
}
// The event route carries BOTH kinds of value: the event id is a PATH segment and
// the project is a QUERY value. Conflating them would read the wrong half of the
// URL and answer another project's event, so one request pins both.
func TestGetEventReadsThePathIDAndTheQueryProject(t *testing.T) {
module := &stubSentry{}
org, project := valuer.GenerateUUID(), valuer.GenerateUUID()
router := routing.Serve(http.MethodGet, "/v1/o11y/sentinel/events/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
NewHandler(module, true, false).GetEvent(w, r.WithContext(authtypes.NewContextWithClaims(r.Context(), authtypes.Claims{OrgID: org.String()})))
}))
req := httptest.NewRequest(http.MethodGet, "/v1/o11y/sentinel/events/ev-42?project="+project.String(), http.NoBody)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Equal(t, "ev-42", module.gotEventID)
assert.Equal(t, project, module.gotProject)
assert.Equal(t, org, module.gotEventOrg)
}
// ingestRoute is the template the runtime registers, guid constraint and all.
// It is spelled here exactly as sentry.go spells it: a stale copy is not a
// smaller version of this test, it is a different route (see the constraint
// test below).
const ingestRoute = "/v1/event/{project:guid}/envelope/"
// The ingest route is the one segment read that decides TENANCY: the project in
// the path is what the DSN key is verified against, so reading the wrong one
// would verify a key against the wrong project.
func TestIngestVerifiesTheProjectTheRouterMatched(t *testing.T) {
module := &stubSentry{}
project := valuer.GenerateUUID()
router := routing.Serve(http.MethodPost, ingestRoute, http.HandlerFunc(NewHandler(module, true, false).EnvelopeIngest))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/event/"+project.String()+"/envelope/", strings.NewReader("{}")))
// Fails closed on the key, which is the stub's answer — and it can only have
// been asked about the project the router matched.
require.Equal(t, http.StatusUnauthorized, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "invalid ingest key")
assert.Equal(t, project, module.gotIngestProj)
}
// The constraint is what keeps the project segment a project id, so a non-uuid
// segment must not reach the handler at all. This is pinned because the
// constraint is silently OPTIONAL: the router names its constraints, an
// unrecognised name is DROPPED rather than refused, and a dropped constraint
// matches everything — a route that answers /v1/event/anything/envelope/ with
// 418 instead of 404, handing the DSN verifier a project that cannot exist.
// That is why the template above is the runtime's own `guid` and is asserted
// here rather than trusted.
func TestIngestConstraintRefusesANonUUIDProject(t *testing.T) {
module := &stubSentry{}
router := routing.Serve(http.MethodPost, ingestRoute, http.HandlerFunc(NewHandler(module, true, false).EnvelopeIngest))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/event/anything/envelope/", strings.NewReader("{}")))
require.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String())
assert.Equal(t, valuer.UUID{}, module.gotIngestProj, "a segment the constraint refuses must not reach the handler")
}