Files
doormile_backend/routes/routes_ai_registry_test.go

191 lines
6.8 KiB
Go

package routes_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"doormile/controllers"
"doormile/internal/ai/playground"
"doormile/utils"
)
// The AI agent registry's gates, over real HTTP (see routes_logistics_test.go
// for what this style of test does and does not prove). Every refusal below
// happens in middleware, before a handler could touch the database.
func tenantToken(t *testing.T, userID, roleID, tenantID int) string {
t.Helper()
tok, err := utils.GenerateToken(userID, "client@partner.example", roleID, tenantID, 1001, jwtSecret)
if err != nil {
t.Fatalf("could not mint a tenant token: %v", err)
}
return tok
}
var aiReads = []string{
"/api/v1/admin/ai/agents",
"/api/v1/admin/ai/agents/EXCEPTION_AGENT",
"/api/v1/admin/ai/skills",
"/api/v1/admin/ai/tools",
"/api/v1/admin/ai/audit",
"/api/v1/admin/ai/insights",
"/api/v1/admin/ai/insights?days=30",
"/api/v1/admin/ai/decisions",
}
var aiWrites = []struct{ method, path, body string }{
{http.MethodPatch, "/api/v1/admin/ai/skills/skill_sla_guardian", `{"enabled":false}`},
{http.MethodPost, "/api/v1/admin/ai/skills", `{"agentid":"CONSOLE_OPS_AGENT","title":"x","tools":["scan_bookings"]}`},
{http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", `{"autonomous":true,"confirm":"EXCEPTION_AGENT"}`},
{http.MethodPost, "/api/v1/admin/ai/playground/run", `{"agentid":"EXCEPTION_AGENT","prompt":"hi"}`},
}
func TestAIRegistryRequiresALogin(t *testing.T) {
app := newApp()
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, "", ""); code != http.StatusUnauthorized {
t.Errorf("GET %s with no token = %d, want 401", p, code)
}
}
}
func TestAIRegistryRefusesNonConsoleRoles(t *testing.T) {
app := newApp()
for _, role := range []int{5, 6, 9} { // miler, hub staff, customer
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, token(t, 1, role), ""); code != http.StatusForbidden {
t.Errorf("role %d GET %s = %d, want 403", role, p, code)
}
}
}
}
// A partner-tenant login is refused outright — even an admin-role one — rather
// than shown an empty registry.
func TestAIRegistryRefusesPartnerTenantLogins(t *testing.T) {
app := newApp()
tok := tenantToken(t, 50, 1, 7)
for _, p := range aiReads {
code, body := do(t, app, http.MethodGet, p, tok, "")
if code != http.StatusForbidden {
t.Errorf("tenant GET %s = %d, want 403", p, code)
}
if code == http.StatusForbidden && !strings.Contains(body, "Doormile staff only") {
t.Errorf("tenant GET %s refused with the wrong message: %s", p, body)
}
}
for _, w := range aiWrites {
if code, _ := do(t, app, w.method, w.path, tok, w.body); code != http.StatusForbidden {
t.Errorf("tenant %s %s = %d, want 403", w.method, w.path, code)
}
}
}
// Managers (3) and executives (4) may read the registry but not change it.
func TestAIRegistryWritesAreAdminOnly(t *testing.T) {
app := newApp()
for _, role := range []int{3, 4} {
for _, w := range aiWrites {
code, body := do(t, app, w.method, w.path, token(t, 1, role), w.body)
if code != http.StatusForbidden {
t.Errorf("role %d %s %s = %d, want 403", role, w.method, w.path, code)
}
if code == http.StatusForbidden && !strings.Contains(body, "insufficient permissions") {
t.Errorf("role %d %s %s refused by the wrong gate: %s", role, w.method, w.path, body)
}
}
}
}
// Doormile staff with roleid 1 get through every gate. There is no database
// in this test, so the handler's first query panics and recover answers 500 —
// which is the proof the route exists and nothing in front of it refused.
func TestAIRegistryAdminPassesEveryGate(t *testing.T) {
app := newApp()
tok := token(t, 1, 1)
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, tok, ""); code == 401 || code == 403 || code == 404 {
t.Errorf("admin GET %s = %d; a gate refused or the route is missing", p, code)
}
}
for _, w := range aiWrites {
if code, _ := do(t, app, w.method, w.path, tok, w.body); code == 401 || code == 403 || code == 404 {
t.Errorf("admin %s %s = %d; a gate refused or the route is missing", w.method, w.path, code)
}
}
// Read roles get through the read gates too.
for _, role := range []int{3, 4} {
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/ai/agents", token(t, 1, role), ""); code == 401 || code == 403 {
t.Errorf("role %d was refused a registry read (%d)", role, code)
}
}
}
func TestInternalRegistryNeedsTheInternalKey(t *testing.T) {
t.Setenv("INTERNAL_API_KEY", "engine-key-for-tests")
app := newApp()
for _, key := range []string{"", "wrong-key"} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/internal/ai/registry", nil)
if key != "" {
req.Header.Set("X-Internal-Key", key)
}
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("internal registry with key %q = %d, want 401", key, resp.StatusCode)
}
}
// A console JWT is not an internal key.
if code, _ := do(t, app, http.MethodGet, "/api/v1/internal/ai/registry", token(t, 1, 1), ""); code != http.StatusUnauthorized {
t.Errorf("internal registry with an admin JWT = %d, want 401", code)
}
}
// The Test playground (Phase 6). With no model client wired the endpoint
// says so plainly (503 with a code the console branches on) — it never
// pretends to run. With one wired, bad input is refused before any database
// or model call.
func TestAIPlaygroundWithoutAClientIs503(t *testing.T) {
app := newApp()
prev := controllers.PlaygroundModel
controllers.PlaygroundModel = nil
defer func() { controllers.PlaygroundModel = prev }()
code, body := do(t, app, http.MethodPost, "/api/v1/admin/ai/playground/run", token(t, 1, 1),
`{"agentid":"EXCEPTION_AGENT","prompt":"hi"}`)
if code != http.StatusServiceUnavailable || !strings.Contains(body, "PLAYGROUND_NOT_CONFIGURED") {
t.Fatalf("no client: %d %s, want 503 PLAYGROUND_NOT_CONFIGURED", code, body)
}
}
type noCallModel struct{ t *testing.T }
func (m noCallModel) Next(context.Context, playground.Request) (playground.Reply, error) {
m.t.Fatal("the model was called for a request that should have been refused")
return playground.Reply{}, nil
}
func TestAIPlaygroundRefusesBadInput(t *testing.T) {
app := newApp()
prev := controllers.PlaygroundModel
controllers.PlaygroundModel = noCallModel{t}
defer func() { controllers.PlaygroundModel = prev }()
for _, b := range []string{
`{"agentid":"EXCEPTION_AGENT","prompt":" "}`,
`{"prompt":"hi"}`,
`{"agentid":"EXCEPTION_AGENT","prompt":"` + strings.Repeat("x", 2001) + `"}`,
`not json`,
} {
if code, body := do(t, app, http.MethodPost, "/api/v1/admin/ai/playground/run", token(t, 1, 1), b); code != http.StatusBadRequest {
t.Errorf("body %.40q = %d %s, want 400", b, code, body)
}
}
}