Files
cairnobs/api/ai/aiapi/handler_test.go
T
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00

363 lines
13 KiB
Go

package aiapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/cairnobs/cairnobs/api/ai/provider"
"github.com/cairnobs/cairnobs/api/ai/router"
)
type fakeProvider struct {
translateResult provider.TranslateResult
completeResult provider.CompleteResult
explainResult provider.ExplainResult
fixResult provider.FixResult
err error
gotExplainReq provider.ExplainRequest
}
func (f *fakeProvider) Translate(context.Context, provider.TranslateRequest) (provider.TranslateResult, error) {
return f.translateResult, f.err
}
func (f *fakeProvider) Complete(context.Context, provider.CompleteRequest) (provider.CompleteResult, error) {
return f.completeResult, f.err
}
func (f *fakeProvider) Explain(_ context.Context, req provider.ExplainRequest) (provider.ExplainResult, error) {
f.gotExplainReq = req
return f.explainResult, f.err
}
func (f *fakeProvider) Fix(context.Context, provider.FixRequest) (provider.FixResult, error) {
return f.fixResult, f.err
}
type fakeSchemaSource struct{}
func (fakeSchemaSource) SchemaContext(context.Context) provider.SchemaContext {
return provider.SchemaContext{Services: []string{"api"}}
}
func newTestHandler(p *fakeProvider) *Handler {
r := router.New(p)
logger := slog.New(slog.NewTextHandler(bytesDiscard{}, nil))
return NewHandler(logger, r, fakeSchemaSource{}, nil, nil)
}
type bytesDiscard struct{}
func (bytesDiscard) Write(p []byte) (int, error) { return len(p), nil }
func doRequest(t *testing.T, h *Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatalf("encoding request body: %v", err)
}
}
req := httptest.NewRequest(method, path, &buf)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestHandleCompleteReturnsSuggestion(t *testing.T) {
p := &fakeProvider{completeResult: provider.CompleteResult{Suggestion: " | stats count"}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: "service=api"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp completeResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Suggestion != " | stats count" {
t.Errorf("Suggestion = %q", resp.Suggestion)
}
}
func TestHandleCompleteDegradesGracefullyOnProviderError(t *testing.T) {
p := &fakeProvider{err: errors.New("provider down")}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: "service=api"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 even on provider failure (graceful degradation), body = %s", rec.Code, rec.Body.String())
}
var resp completeResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Suggestion != "" {
t.Errorf("Suggestion = %q, want empty on provider failure", resp.Suggestion)
}
}
func TestHandleCompleteEmptyPrefixSkipsProviderCall(t *testing.T) {
p := &fakeProvider{err: errors.New("should not be called")}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/complete", completeRequest{QueryPrefix: " "})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestHandleExplainReturnsExplanation(t *testing.T) {
p := &fakeProvider{explainResult: provider.ExplainResult{Explanation: "counts errors per host"}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: "severity=ERROR | stats count by host"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp explainResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Explanation == "" {
t.Error("expected a non-empty explanation")
}
}
func TestHandleExplainEmptyQueryIsBadRequest(t *testing.T) {
h := newTestHandler(&fakeProvider{})
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: ""})
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestHandleExplainProviderErrorIsBadGateway(t *testing.T) {
p := &fakeProvider{err: errors.New("model unavailable")}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/explain", explainRequest{Query: "service=api"})
if rec.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502", rec.Code)
}
}
func TestHandleFixMissingErrorFieldsIsBadRequest(t *testing.T) {
h := newTestHandler(&fakeProvider{})
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "service=api"})
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (neither parseError nor executionError set)", rec.Code)
}
}
func TestHandleFixAssessesSuggestedQueryCost(t *testing.T) {
// The provider suggests a fix that aggregates with no time bound --
// costguard should reject it (an aggregation gets no implicit row
// cap the way a raw-row fetch does), and the handler must mark it
// Blocked rather than silently offering it as runnable. Needs a real
// leading pipe stage -- bare words with no "|" parse as free-text
// search terms, not an aggregation, per the query grammar.
p := &fakeProvider{fixResult: provider.FixResult{
SuggestedQuery: "service=api | stats count by host",
Explanation: "removed the invalid field reference",
Confidence: provider.ConfidenceHigh,
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "bogus_field=1 | stats count by host", ParseError: "unknown field"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp fixResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if !resp.Blocked {
t.Errorf("resp = %+v, want Blocked=true for an unbounded aggregation suggestion", resp)
}
if len(resp.CostWarnings) == 0 {
t.Error("expected non-empty CostWarnings")
}
}
func TestHandleFixBoundedSuggestionIsNotBlocked(t *testing.T) {
p := &fakeProvider{fixResult: provider.FixResult{
SuggestedQuery: "earliest=-1h | stats count by host",
Confidence: provider.ConfidenceHigh,
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/fix", fixRequest{Query: "stats count by host", ParseError: "no time range"})
var resp fixResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Blocked {
t.Errorf("resp = %+v, want Blocked=false for a properly time-bounded suggestion", resp)
}
}
func TestHandleOptimizeNoFindingsForBoundedQuery(t *testing.T) {
h := newTestHandler(&fakeProvider{})
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "earliest=-1h severity=ERROR | stats count by host"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp optimizeResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if len(resp.Findings) != 0 || resp.Phrased != "" {
t.Errorf("resp = %+v, want no findings for a bounded query", resp)
}
}
func TestHandleOptimizeSuggestsMechanicalFixForMissingTimeRange(t *testing.T) {
p := &fakeProvider{explainResult: provider.ExplainResult{Explanation: "add a time range to avoid scanning everything"}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "stats count by host"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp optimizeResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if len(resp.Findings) == 0 {
t.Error("expected at least one finding for an unbounded aggregation")
}
if resp.SuggestedQuery != "earliest=-1h stats count by host" {
t.Errorf("SuggestedQuery = %q", resp.SuggestedQuery)
}
if resp.Phrased == "" {
t.Error("expected a phrased explanation from the (fake) provider")
}
if len(p.gotExplainReq.RuleFindings) == 0 {
t.Error("expected Explain to have been called with RuleFindings set")
}
}
func TestHandleOptimizeDegradesGracefullyWhenPhraseFails(t *testing.T) {
p := &fakeProvider{err: errors.New("model down")}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "stats count by host"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 even when phrasing fails, body = %s", rec.Code, rec.Body.String())
}
var resp optimizeResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if len(resp.Findings) == 0 {
t.Error("Findings should still be populated (rule-based, no model needed) even if phrasing fails")
}
if resp.Phrased != "" {
t.Errorf("Phrased = %q, want empty when the provider fails", resp.Phrased)
}
}
func TestHandleOptimizeInvalidQueryIsBadRequest(t *testing.T) {
h := newTestHandler(&fakeProvider{})
rec := doRequest(t, h, "POST", "/ai/optimize", optimizeRequest{Query: "| stats"})
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 for an uncompilable query", rec.Code)
}
}
// ---- translate ----
func TestHandleTranslateEmptyNLQueryIsBadRequest(t *testing.T) {
h := newTestHandler(&fakeProvider{})
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: " "})
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestHandleTranslateProviderErrorIsBadGateway(t *testing.T) {
p := &fakeProvider{err: errors.New("model unavailable")}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors in the last hour"})
if rec.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502", rec.Code)
}
}
func TestHandleTranslateBoundedQueryCompilesCleanly(t *testing.T) {
p := &fakeProvider{translateResult: provider.TranslateResult{
Query: "earliest=-1h severity=ERROR | stats count by service",
Confidence: provider.ConfidenceHigh,
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors per service in the last hour"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp translateResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if !resp.Compiles || resp.CompileError != "" {
t.Errorf("resp = %+v, want Compiles=true, no CompileError", resp)
}
if resp.Blocked || len(resp.CostWarnings) != 0 {
t.Errorf("resp = %+v, want no cost warnings for a time-bounded query", resp)
}
if resp.Confidence != string(provider.ConfidenceHigh) {
t.Errorf("Confidence = %q", resp.Confidence)
}
}
func TestHandleTranslateUnboundedQueryIsBlocked(t *testing.T) {
// The model produced a syntactically valid but unbounded aggregation
// -- task 9's explicit requirement that translation results run
// through the same cost guard AI-suggested fixes do.
p := &fakeProvider{translateResult: provider.TranslateResult{
Query: "severity=ERROR | stats count by service",
Confidence: provider.ConfidenceHigh,
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "errors by service"})
var resp translateResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if !resp.Compiles {
t.Fatalf("resp = %+v, want Compiles=true", resp)
}
if !resp.Blocked || len(resp.CostWarnings) == 0 {
t.Errorf("resp = %+v, want Blocked=true with cost warnings for an unbounded aggregation", resp)
}
}
func TestHandleTranslateNonCompilingQueryIsHonestlyReported(t *testing.T) {
// The model returned something that doesn't actually parse -- a
// real, distinct outcome from low confidence (a confident model can
// still produce invalid syntax); the handler must say so plainly,
// not silently drop it or crash.
p := &fakeProvider{translateResult: provider.TranslateResult{
Query: "| stats count",
Confidence: provider.ConfidenceHigh,
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "something odd"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (a non-compiling suggestion is a reportable outcome, not an HTTP error), body = %s", rec.Code, rec.Body.String())
}
var resp translateResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Compiles || resp.CompileError == "" {
t.Errorf("resp = %+v, want Compiles=false with a CompileError", resp)
}
}
func TestHandleTranslateLowConfidenceCarriesReason(t *testing.T) {
p := &fakeProvider{translateResult: provider.TranslateResult{
Confidence: provider.ConfidenceLow,
LowConfidenceReason: "not sure what 'weird stuff' refers to",
}}
h := newTestHandler(p)
rec := doRequest(t, h, "POST", "/ai/translate", translateRequest{NLQuery: "show me weird stuff"})
var resp translateResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Query != "" {
t.Errorf("Query = %q, want empty for a low-confidence non-answer", resp.Query)
}
if resp.Confidence != string(provider.ConfidenceLow) || resp.LowConfidenceReason == "" {
t.Errorf("resp = %+v, want low confidence with a reason", resp)
}
if resp.Compiles {
t.Errorf("resp = %+v, want Compiles=false when Query is empty", resp)
}
}