package main import ( "context" "net/http" "net/http/httptest" "strings" "testing" "time" hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" ) // stubEcosystem wires nexus+hexis clients at the given base URLs. func stubEcosystem(nexusURL, hexisURL string) *ecosystemWiring { return &ecosystemWiring{ nexus: newNexusClient(nexusURL), hexis: hexisclient.New(hexisURL), } } // newHexisTestHandler builds a reactiveHandler backed by fake nexus+hexis // servers. resolveBody is returned verbatim from nexus /resolve; caps is the // capability list; executed records whether Hexis /execute was called. func newHexisTestHandler(t *testing.T, resolveBody string, caps string) (*reactiveHandler, *bool) { t.Helper() executed := new(bool) nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(resolveBody)) })) t.Cleanup(nexus.Close) hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case strings.HasPrefix(r.URL.Path, "/api/v1/capabilities"): w.Write([]byte(caps)) case r.URL.Path == "/api/v1/execute": *executed = true w.Write([]byte(`{"id":"exec_1","status":"succeeded"}`)) default: http.NotFound(w, r) } })) t.Cleanup(hexis.Close) st := newTestStore(t) now := time.Now() return &reactiveHandler{ api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }, ecosystem: stubEcosystem(nexus.URL, hexis.URL), }, executed } func actDec(text string) router.Decision { return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: text, Fn: "restart", HasFn: true}} } func TestHexisMutatingRequiresConfirm(t *testing.T) { ctx := context.Background() resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` caps := `[{"id":"cap_restart","name":"restart","read_only":false,"risk":"high"}]` h, executed := newHexisTestHandler(t, resolved, caps) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if !strings.Contains(reply, "да") { t.Fatalf("mutating cap should ask to confirm, got %q", reply) } if *executed { t.Fatal("mutating cap must NOT execute before confirmation") } if h.pendingHexis == nil || h.pendingHexis.capabilityID != "cap_restart" { t.Fatalf("expected pending hexis bound to cap_restart, got %+v", h.pendingHexis) } // The follow-up "да" turn executes exactly the parked capability. confirmReply, handled := h.resolveConfirm(ctx, "да") if !handled || !strings.Contains(confirmReply, "выполнена") { t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply) } if !*executed { t.Fatal("confirmed mutating cap should have executed") } if h.pendingHexis != nil { t.Fatal("pending should be cleared after confirm") } } func TestHexisConfirmNoDoesNotExecute(t *testing.T) { ctx := context.Background() resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` caps := `[{"id":"cap_restart","name":"restart","read_only":false}]` h, executed := newHexisTestHandler(t, resolved, caps) _ = h.handleHexisAct(ctx, actDec("muzick indexer")) reply, handled := h.resolveConfirm(ctx, "нет") if !handled || !strings.Contains(reply, "отменила") { t.Fatalf("no should cancel, got handled=%v reply=%q", handled, reply) } if *executed { t.Fatal("declined cap must not execute") } } func TestHexisReadOnlyExecutesImmediately(t *testing.T) { ctx := context.Background() resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` caps := `[{"id":"cap_status","name":"restart","read_only":true}]` h, executed := newHexisTestHandler(t, resolved, caps) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if !*executed { t.Fatal("read-only cap should execute without confirmation") } if h.pendingHexis != nil { t.Fatal("read-only cap should not park a confirmation") } if !strings.Contains(reply, "выполнена") { t.Fatalf("unexpected reply %q", reply) } } func TestHexisAmbiguousAsksClarification(t *testing.T) { ctx := context.Background() ambiguous := `{"status":"ambiguous","candidates":[{"entity_id":"ent_muzick","display_name":"Muzick indexer"},{"entity_id":"ent_manga","display_name":"Manga indexer"}]}` h, executed := newHexisTestHandler(t, ambiguous, `[]`) reply := h.handleHexisAct(ctx, actDec("the indexer")) if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Manga indexer") { t.Fatalf("ambiguous should list candidates, got %q", reply) } if *executed { t.Fatal("ambiguous target must never execute") } } // TestHexisResolveFlatShapeAccepted covers ECOSYSTEM-SPEC.md §1.5's documented // flat resolve response (entity_id/entity_type/display_name at the top level, // no nested entity object) alongside the nested shape Maven already decodes. func TestHexisResolveFlatShapeAccepted(t *testing.T) { ctx := context.Background() flat := `{"status":"resolved","entity_id":"ent_muzick","entity_type":"service","display_name":"Muzick indexer"}` caps := `[{"id":"cap_status","name":"restart","read_only":true}]` h, executed := newHexisTestHandler(t, flat, caps) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if !*executed { t.Fatalf("flat-shaped resolved entity should still execute, got reply %q", reply) } } // TestHexisNexusErrorFailsClosed covers the P0 audit finding: a genuine Nexus // dependency failure must stop the ecosystem action and report degradation, // never silently fall through to the local system command executor. func TestHexisNexusErrorFailsClosed(t *testing.T) { ctx := context.Background() nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) })) t.Cleanup(nexus.Close) hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("hexis must not be contacted when nexus resolve fails") })) t.Cleanup(hexis.Close) st := newTestStore(t) now := time.Now() h := &reactiveHandler{ api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }, ecosystem: stubEcosystem(nexus.URL, hexis.URL), } reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if reply == "" { t.Fatal("nexus dependency failure must not fall through with an empty reply") } if strings.Contains(reply, "выполнена") { t.Fatalf("nexus dependency failure must not report success, got %q", reply) } } // TestHexisUnavailableFailsClosed covers the same invariant for a resolved // entity whose Hexis capability discovery then fails. func TestHexisUnavailableFailsClosed(t *testing.T) { ctx := context.Background() resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` nexus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(resolved)) })) t.Cleanup(nexus.Close) hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", http.StatusServiceUnavailable) })) t.Cleanup(hexis.Close) st := newTestStore(t) now := time.Now() h := &reactiveHandler{ api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }, ecosystem: stubEcosystem(nexus.URL, hexis.URL), } reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if reply == "" { t.Fatal("hexis dependency failure must not fall through with an empty reply") } if strings.Contains(reply, "выполнена") { t.Fatalf("hexis dependency failure must not report success, got %q", reply) } } // TestHexisNotFoundStillFallsThrough ensures the fail-closed fix above is // scoped to genuine dependency errors: a resolved-but-empty ("not_found") // Nexus response — meaning the text simply isn't a known entity, not that // Nexus is broken — must still fall through to the local command executor. func TestHexisNotFoundStillFallsThrough(t *testing.T) { ctx := context.Background() notFound := `{"status":"not_found"}` h, executed := newHexisTestHandler(t, notFound, `[]`) reply := h.handleHexisAct(ctx, actDec("turn off the lights")) if reply != "" { t.Fatalf("not_found resolution should fall through with empty reply, got %q", reply) } if *executed { t.Fatal("not_found resolution must never execute a hexis capability") } }