Pulse/internal/api/ai_findings_endpoint_test.go

536 lines
19 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// --- HandleGetInvestigation ---
func TestHandleGetInvestigation_MethodNotAllowed(t *testing.T) {
handler := newTestAISettingsHandlerLite()
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(method, "/api/ai/findings/f-1/investigation", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigation(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s: expected 405, got %d", method, rec.Code)
}
}
}
func TestHandleGetInvestigation_EmptyFindingID(t *testing.T) {
handler := newTestAISettingsHandlerLite()
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings//investigation", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigation(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty finding ID, got %d", rec.Code)
}
var resp APIError
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode error: %v", err)
}
if resp.Code != "missing_id" {
t.Fatalf("expected code missing_id, got %q", resp.Code)
}
}
func TestHandleGetInvestigation_FindingIDTooLong(t *testing.T) {
handler := newTestAISettingsHandlerLite()
longID := strings.Repeat("a", maxFindingIDLength+1)
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/"+longID+"/investigation", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigation(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for oversized finding ID, got %d", rec.Code)
}
var resp APIError
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode error: %v", err)
}
if resp.Code != "invalid_id" {
t.Fatalf("expected code invalid_id, got %q", resp.Code)
}
}
func TestHandleGetInvestigation_NoPatrolService(t *testing.T) {
// newTestAISettingsHandlerLite creates an AI service without state provider,
// so patrol won't be initialized.
handler := newTestAISettingsHandlerLite()
svc := handler.GetAIService(context.Background())
if svc.GetPatrolService() != nil {
t.Skip("patrol service unexpectedly initialized")
}
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/investigation", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigation(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for nil patrol, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestHandleGetInvestigation_NoAIService(t *testing.T) {
handler := &AISettingsHandler{}
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/investigation", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigation(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for missing AI service, got %d: %s", rec.Code, rec.Body.String())
}
var resp APIError
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode error: %v", err)
}
if resp.Code != "not_initialized" {
t.Fatalf("expected code not_initialized, got %q", resp.Code)
}
}
func TestHandleGetInvestigation_NoOrchestrator(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
svc := handler.GetAIService(context.Background())
svc.SetStateProvider(&MockStateProvider{})
patrol := svc.GetPatrolService()
if patrol == nil {
t.Fatalf("expected patrol service")
}
// Don't set orchestrator — it should be nil
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/investigation", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigation(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for nil orchestrator, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestHandleGetInvestigation_NotFound(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
svc := handler.GetAIService(context.Background())
svc.SetStateProvider(&MockStateProvider{})
patrol := svc.GetPatrolService()
if patrol == nil {
t.Fatalf("expected patrol service")
}
// Orchestrator with no matching session
orchestrator := &stubInvestigationOrchestrator{session: nil}
patrol.SetInvestigationOrchestrator(orchestrator)
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/nonexistent/investigation", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigation(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for missing investigation, got %d: %s", rec.Code, rec.Body.String())
}
var resp APIError
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode error: %v", err)
}
if resp.Code != "not_found" {
t.Fatalf("expected code not_found, got %q", resp.Code)
}
}
func TestHandleGetInvestigation_FindingIDExtractionFromPath(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
svc := handler.GetAIService(context.Background())
svc.SetStateProvider(&MockStateProvider{})
patrol := svc.GetPatrolService()
if patrol == nil {
t.Fatalf("expected patrol service")
}
// Normal hex finding ID — the real production format
session := &ai.InvestigationSession{
ID: "inv-1",
FindingID: "abcdef0123456789",
SessionID: "session-1",
Status: "completed",
StartedAt: time.Now(),
}
orchestrator := &stubInvestigationOrchestrator{session: session}
patrol.SetInvestigationOrchestrator(orchestrator)
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/abcdef0123456789/investigation", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigation(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for valid hex finding ID, got %d: %s", rec.Code, rec.Body.String())
}
var resp ai.InvestigationSession
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.FindingID != "abcdef0123456789" {
t.Fatalf("expected findingID abcdef0123456789, got %q", resp.FindingID)
}
}
func TestHandleGetInvestigation_MalformedPathNoID(t *testing.T) {
// When the path is /api/ai/findings/investigation (no actual finding ID segment),
// TrimPrefix+TrimSuffix yields "investigation" as the ID, which is not empty
// and passes validation. The orchestrator returns nil → 404.
// This documents current behavior: non-crashable, returns 404.
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
svc := handler.GetAIService(context.Background())
svc.SetStateProvider(&MockStateProvider{})
patrol := svc.GetPatrolService()
if patrol == nil {
t.Fatalf("expected patrol service")
}
orchestrator := &stubInvestigationOrchestrator{session: nil}
patrol.SetInvestigationOrchestrator(orchestrator)
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/investigation", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigation(rec, req)
// Should get 404 since no finding with ID "investigation" exists
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for malformed path without real ID, got %d: %s", rec.Code, rec.Body.String())
}
}
// --- HandleGetInvestigationMessages ---
func TestHandleGetInvestigationMessages_MethodNotAllowed(t *testing.T) {
handler := newTestAISettingsHandlerLite()
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
req := httptest.NewRequest(method, "/api/ai/findings/f-1/investigation/messages", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigationMessages(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s: expected 405, got %d", method, rec.Code)
}
}
}
func TestHandleGetInvestigationMessages_EmptyFindingID(t *testing.T) {
handler := newTestAISettingsHandlerLite()
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings//investigation/messages", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigationMessages(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty finding ID, got %d", rec.Code)
}
}
func TestHandleGetInvestigationMessages_FindingIDTooLong(t *testing.T) {
handler := newTestAISettingsHandlerLite()
longID := strings.Repeat("x", maxFindingIDLength+1)
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/"+longID+"/investigation/messages", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigationMessages(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for oversized finding ID, got %d", rec.Code)
}
}
func TestHandleGetInvestigationMessages_NoPatrolService(t *testing.T) {
handler := newTestAISettingsHandlerLite()
svc := handler.GetAIService(context.Background())
if svc.GetPatrolService() != nil {
t.Skip("patrol service unexpectedly initialized")
}
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/investigation/messages", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigationMessages(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", rec.Code)
}
}
func TestHandleGetInvestigationMessages_NoAIService(t *testing.T) {
handler := &AISettingsHandler{}
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/investigation/messages", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigationMessages(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for missing AI service, got %d: %s", rec.Code, rec.Body.String())
}
var resp APIError
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode error: %v", err)
}
if resp.Code != "not_initialized" {
t.Fatalf("expected code not_initialized, got %q", resp.Code)
}
}
func TestHandleGetInvestigationMessages_NoOrchestrator(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
svc := handler.GetAIService(context.Background())
svc.SetStateProvider(&MockStateProvider{})
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/investigation/messages", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigationMessages(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestHandleGetInvestigationMessages_InvestigationNotFound(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
svc := handler.GetAIService(context.Background())
svc.SetStateProvider(&MockStateProvider{})
patrol := svc.GetPatrolService()
if patrol == nil {
t.Fatalf("expected patrol service")
}
orchestrator := &stubInvestigationOrchestrator{session: nil}
patrol.SetInvestigationOrchestrator(orchestrator)
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/nonexistent/investigation/messages", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigationMessages(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestHandleGetInvestigationMessages_NoChatService(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
svc := handler.GetAIService(context.Background())
svc.SetStateProvider(&MockStateProvider{})
// Don't set chat service - leave it nil
patrol := svc.GetPatrolService()
if patrol == nil {
t.Fatalf("expected patrol service")
}
session := &ai.InvestigationSession{
ID: "inv-1",
FindingID: "f-1",
SessionID: "session-1",
Status: "completed",
StartedAt: time.Now(),
}
orchestrator := &stubInvestigationOrchestrator{session: session}
patrol.SetInvestigationOrchestrator(orchestrator)
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/investigation/messages", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigationMessages(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for nil chat service, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestHandleGetInvestigationMessages_EmptyMessageList(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
svc := handler.GetAIService(context.Background())
svc.SetStateProvider(&MockStateProvider{})
svc.SetChatService(&stubChatService{messages: []ai.ChatMessage{}})
patrol := svc.GetPatrolService()
if patrol == nil {
t.Fatalf("expected patrol service")
}
session := &ai.InvestigationSession{
ID: "inv-1",
FindingID: "f-1",
SessionID: "session-1",
Status: "completed",
StartedAt: time.Now(),
}
orchestrator := &stubInvestigationOrchestrator{session: session}
patrol.SetInvestigationOrchestrator(orchestrator)
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/investigation/messages", nil)
rec := httptest.NewRecorder()
handler.HandleGetInvestigationMessages(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["investigation_id"] != "inv-1" {
t.Fatalf("expected investigation_id inv-1, got %v", resp["investigation_id"])
}
msgs := resp["messages"].([]interface{})
if len(msgs) != 0 {
t.Fatalf("expected 0 messages, got %d", len(msgs))
}
}
// --- Router-level dispatch ---
func TestFindingsRouterDispatch_UnknownSubpath(t *testing.T) {
rawToken := "ai-findings-dispatch-test.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAIExecute}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/unknown", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for unknown sub-path, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFindingsRouterDispatch_RequiresRelayMobileOrAIExecuteScopeForReadEndpoints(t *testing.T) {
// Token with only ai:chat scope — should be denied investigation reads
// because they now accept either the dedicated mobile relay capability or
// the legacy ai:execute scope.
rawToken := "ai-findings-scope-test.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []struct {
method string
path string
}{
{http.MethodGet, "/api/ai/findings/f-1/investigation"},
{http.MethodGet, "/api/ai/findings/f-1/investigation/messages"},
}
for _, tc := range paths {
var body *strings.Reader
if tc.method == http.MethodPost {
body = strings.NewReader(`{}`)
}
var req *http.Request
if body != nil {
req = httptest.NewRequest(tc.method, tc.path, body)
} else {
req = httptest.NewRequest(tc.method, tc.path, nil)
}
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("%s %s: expected 403 for wrong scope, got %d", tc.method, tc.path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeRelayMobileAccess) {
t.Errorf("%s %s: expected missing scope response to mention %q, got %q", tc.method, tc.path, config.ScopeRelayMobileAccess, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
t.Errorf("%s %s: expected missing scope response to mention %q, got %q", tc.method, tc.path, config.ScopeAIExecute, rec.Body.String())
}
}
}
func TestFindingsRouterDispatch_MutationsStillRequireAIExecuteScope(t *testing.T) {
rawToken := "ai-findings-mutation-scope-test.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/ai/findings/f-1/reinvestigate",
"/api/ai/findings/f-1/reapprove",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("POST %s: expected 403 for missing ai:execute scope, got %d", path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) {
t.Errorf("POST %s: expected missing scope response to mention %q, got %q", path, config.ScopeAIExecute, rec.Body.String())
}
}
}
func TestFindingsRouterDispatch_CommunityReinvestigateReturns402(t *testing.T) {
rawToken := "ai-findings-community-reinv.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAIExecute}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/ai/findings/f-1/reinvestigate", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("expected 402 for Community reinvestigate, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestFindingsRouterDispatch_CommunityReapproveReturns402(t *testing.T) {
rawToken := "ai-findings-community-reapp.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeAIExecute}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/ai/findings/f-1/reapprove", strings.NewReader(`{}`))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("expected 402 for Community reapprove, got %d: %s", rec.Code, rec.Body.String())
}
}
// --- maxFindingIDLength constant ---
func TestMaxFindingIDLength_IsReasonable(t *testing.T) {
// Verify the constant is set to a reasonable value.
// Real finding IDs are 16 hex chars; the limit should be generous but bounded.
if maxFindingIDLength < 16 {
t.Fatalf("maxFindingIDLength=%d is too small for real finding IDs (16 chars)", maxFindingIDLength)
}
if maxFindingIDLength > 1024 {
t.Fatalf("maxFindingIDLength=%d is unreasonably large", maxFindingIDLength)
}
}