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.
257 lines
9.0 KiB
Go
257 lines
9.0 KiB
Go
// Phase 7 task 13: end-to-end wiring tests distinct from handler_test.go
|
|
// and ai/provider/ollama's own tests. Those two files each cover one
|
|
// layer in isolation -- handler_test.go's fakeProvider satisfies
|
|
// provider.Provider directly, bypassing HTTP/JSON/prompt construction
|
|
// entirely; ollama_test.go exercises ollama.Client's wire-format parsing
|
|
// against a stub server, but never through aiapi.Handler's actual HTTP
|
|
// routes. Neither proves the seam between them actually works: a real
|
|
// *ollama.Client wired through *router.Router into a real *Handler,
|
|
// driven by real HTTP requests against the registered routes, with real
|
|
// planner.Compile/costguard.Assess in the loop.
|
|
//
|
|
// No live Ollama or model is needed or used -- mockOllamaServer stands
|
|
// in for Ollama's real /api/chat wire contract (same technique used for
|
|
// this phase's live browser verification, see /docs/phase-7-ai-design.md,
|
|
// just returning a fixed canned response instead of one selected by
|
|
// inspecting the prompt) with a deterministic canned JSON body, which is
|
|
// exactly what makes this suite fast and safe to run in CI -- see the
|
|
// "CI testability" section of the design doc for why testing against a
|
|
// real model is deliberately kept out of this suite instead.
|
|
package aiapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/cairnobs/cairnobs/api/ai/provider/ollama"
|
|
"github.com/cairnobs/cairnobs/api/ai/router"
|
|
)
|
|
|
|
// jsonBody marshals v for use as an http.Post body -- the integration
|
|
// tests below drive real HTTP requests against a real httptest.Server
|
|
// (not handler_test.go's doRequest/ResponseRecorder shortcut), since the
|
|
// point of this file is proving the routes are actually reachable over
|
|
// real HTTP, not just that Handler's methods dispatch correctly.
|
|
func jsonBody(t *testing.T, v any) io.Reader {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("marshaling request body: %v", err)
|
|
}
|
|
return bytes.NewReader(b)
|
|
}
|
|
|
|
type fakeInteractionLogger struct {
|
|
entries []InteractionEntry
|
|
}
|
|
|
|
func (f *fakeInteractionLogger) LogInteraction(_ context.Context, entry InteractionEntry) error {
|
|
f.entries = append(f.entries, entry)
|
|
return nil
|
|
}
|
|
|
|
// mockOllamaServer returns an httptest.Server that answers any
|
|
// POST /api/chat with the given assistant message content, matching
|
|
// Ollama's real response envelope shape byte-for-byte (see
|
|
// ollama.go's chatResponse).
|
|
func mockOllamaServer(t *testing.T, content string) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/chat" {
|
|
t.Errorf("unexpected request to %s, want /api/chat", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"message": map[string]string{"role": "assistant", "content": content},
|
|
})
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func newIntegrationHandler(t *testing.T, ollamaContent string) *Handler {
|
|
t.Helper()
|
|
mock := mockOllamaServer(t, ollamaContent)
|
|
client := ollama.New(mock.URL, "test-model")
|
|
r := router.New(client)
|
|
logger := slog.New(slog.NewTextHandler(bytesDiscard{}, nil))
|
|
return NewHandler(logger, r, fakeSchemaSource{}, nil, nil)
|
|
}
|
|
|
|
// TestIntegrationTranslateEndToEnd proves the full path -- HTTP request
|
|
// in, real ollama.Client HTTP call out to the mock, real JSON parsing,
|
|
// real planner.Compile, real costguard.Assess, HTTP response out --
|
|
// works for a query that should pass cleanly (time-bounded, no
|
|
// aggregation).
|
|
func TestIntegrationTranslateEndToEnd(t *testing.T) {
|
|
h := newIntegrationHandler(t, `{"query":"earliest=-1h severity=ERROR","confidence":"high"}`)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Post(srv.URL+"/ai/translate", "application/json",
|
|
jsonBody(t, map[string]string{"nlQuery": "errors in the last hour"}))
|
|
if err != nil {
|
|
t.Fatalf("POST /ai/translate: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
|
|
var got translateResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if got.Query != "earliest=-1h severity=ERROR" {
|
|
t.Errorf("query = %q", got.Query)
|
|
}
|
|
if !got.Compiles {
|
|
t.Error("compiles = false, want true -- this query is valid pipe syntax")
|
|
}
|
|
if got.Blocked {
|
|
t.Errorf("blocked = true, want false: %v", got.CostWarnings)
|
|
}
|
|
if got.Confidence != "high" {
|
|
t.Errorf("confidence = %q, want high", got.Confidence)
|
|
}
|
|
}
|
|
|
|
// TestIntegrationTranslateBlockedByCostGuard proves costguard is
|
|
// actually reached through the full HTTP stack, not just unit-tested
|
|
// against costguard.Assess in isolation -- an unbounded aggregation
|
|
// (stats, no time filter) must come back Blocked.
|
|
func TestIntegrationTranslateBlockedByCostGuard(t *testing.T) {
|
|
h := newIntegrationHandler(t, `{"query":"severity=ERROR | stats count by service","confidence":"high"}`)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Post(srv.URL+"/ai/translate", "application/json",
|
|
jsonBody(t, map[string]string{"nlQuery": "error count by service"}))
|
|
if err != nil {
|
|
t.Fatalf("POST /ai/translate: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var got translateResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if !got.Compiles {
|
|
t.Fatalf("compiles = false, want true: %s", got.CompileError)
|
|
}
|
|
if !got.Blocked {
|
|
t.Error("blocked = false, want true -- unbounded aggregation should be rejected by costguard")
|
|
}
|
|
if len(got.CostWarnings) == 0 {
|
|
t.Error("costWarnings is empty, want at least one reason")
|
|
}
|
|
}
|
|
|
|
// TestIntegrationFixEndToEnd proves the same seam for /ai/fix.
|
|
func TestIntegrationFixEndToEnd(t *testing.T) {
|
|
h := newIntegrationHandler(t, `{"suggested_query":"earliest=-1h severity=ERROR","explanation":"added a time bound","confidence":"high"}`)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Post(srv.URL+"/ai/fix", "application/json", jsonBody(t, map[string]string{
|
|
"query": "severity=ERROR",
|
|
"language": "spl",
|
|
"executionError": "query timed out: no time range specified",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("POST /ai/fix: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
|
|
var got fixResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if got.SuggestedQuery != "earliest=-1h severity=ERROR" {
|
|
t.Errorf("suggestedQuery = %q", got.SuggestedQuery)
|
|
}
|
|
if got.Blocked {
|
|
t.Errorf("blocked = true, want false: %v", got.CostWarnings)
|
|
}
|
|
}
|
|
|
|
// TestIntegrationCompleteEndToEnd proves the same seam for /ai/complete
|
|
// (Track A's ghost-text autocomplete).
|
|
func TestIntegrationCompleteEndToEnd(t *testing.T) {
|
|
h := newIntegrationHandler(t, `{"suggestion":" severity=ERROR"}`)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Post(srv.URL+"/ai/complete", "application/json", jsonBody(t, map[string]string{
|
|
"queryPrefix": "service=api ",
|
|
"language": "spl",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("POST /ai/complete: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var got completeResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if got.Suggestion != " severity=ERROR" {
|
|
t.Errorf("suggestion = %q", got.Suggestion)
|
|
}
|
|
}
|
|
|
|
// TestIntegrationLogInteractionEndToEnd proves /ai/log-interaction's
|
|
// full HTTP decode+validate+dispatch path, using a fake InteractionLogger
|
|
// (an in-process Go fake is the right seam here, not another mock HTTP
|
|
// server -- the real implementation is enterprise/internal/audit, which
|
|
// needs a live Postgres and is covered by that package's own tests).
|
|
func TestIntegrationLogInteractionEndToEnd(t *testing.T) {
|
|
logger := &fakeInteractionLogger{}
|
|
r := router.New(&fakeProvider{})
|
|
h := NewHandler(slog.New(slog.NewTextHandler(bytesDiscard{}, nil)), r, fakeSchemaSource{}, nil, logger)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
resp, err := http.Post(srv.URL+"/ai/log-interaction", "application/json", jsonBody(t, map[string]any{
|
|
"operation": "translate",
|
|
"input": "errors in the last hour",
|
|
"output": "earliest=-1h severity=ERROR",
|
|
"confidence": "high",
|
|
"accepted": true,
|
|
"edited": false,
|
|
"finalQuery": "earliest=-1h severity=ERROR",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("POST /ai/log-interaction: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
|
}
|
|
if len(logger.entries) != 1 {
|
|
t.Fatalf("got %d logged entries, want 1", len(logger.entries))
|
|
}
|
|
if logger.entries[0].Operation != "translate" || !logger.entries[0].Accepted {
|
|
t.Errorf("logged entry = %+v", logger.entries[0])
|
|
}
|
|
}
|