d9fa4d6613
Vikunja #268 (P0): handleHexisAct swallowed genuine Nexus resolve errors and Hexis capability-discovery errors into "" or an empty capability list, which fell through to the local system command executor — a dependency outage silently looked identical to "not an ecosystem entity" or "no capabilities registered", violating the spec's degrade-independently / never-silent-all-clear invariant. - resolveEntityReference's error is now distinguished from a legitimate not_found: only the latter falls through. - discoverCapabilities now returns (caps, err) instead of collapsing a Hexis failure into an empty slice; a real error stops the action with a degraded-mode spoken reply instead of reaching h.tools.Exec. - nexusResolveResult gains a custom UnmarshalJSON to accept the flat entity_id/entity_type/display_name shape from ECOSYSTEM-SPEC.md §1.5 (Nexus now emits both shapes; Maven now reads both). - Added regression tests: flat-shape resolve, Nexus error fails closed, Hexis error fails closed, not_found still falls through to local exec.
241 lines
8.6 KiB
Go
241 lines
8.6 KiB
Go
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")
|
|
}
|
|
}
|