191 lines
6.8 KiB
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)
|
|
}
|
|
}
|
|
}
|