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/phraser" "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 } // actDec builds an act decision about subject. The verb is always "restart": // the argument is the utterance the entity is resolved from, never the verb, // so actDec("restart") reads as a verb and is not one. func actDec(subject string) router.Decision { return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: subject, 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 || !actRan(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 !actRan(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 actRan(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 actRan(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") } } // actRan — the reply is the line she says when a capability ran against an // entity. The tests used to look for the substring "выполнена", which was a // literal out of the act file: the review reworded that line to "готово: {name}" // and seventeen assertions went with it (Vikunja #521). func actRan(reply string) bool { return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply) } // muzickIndexer — the display name every ecosystem fixture resolves to. const muzickIndexer = "Muzick indexer" // read_only used to be the whole decision on this path, which meant a // capability that destroys what it names got the same single spoken "да" as one // that restarts a service. Hexis declares the tier and the voice path is not an // authorised surface for the top one (Vikunja #523). func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) { ctx := context.Background() resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` caps := `[{"id":"cap_wipe","name":"restart","read_only":false,"risk":"irreversible","requires_confirmation":true}]` h, executed := newHexisTestHandler(t, resolved, caps) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if *executed { t.Fatal("an irreversible capability ran from the voice path") } if h.pendingHexis != nil { t.Fatal("an irreversible capability parked a confirm; a spoken да is not enough authority") } if !strings.Contains(reply, "не вернуть") { t.Errorf("reply = %q; want it to name why she will not run it", reply) } } // The other half: Hexis calling a capability safe is enough to run it, even // though read_only is the field that used to decide. Nothing here re-derives. func TestHexisSafeCapabilityRunsOnItsDeclaredTier(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,"risk":"safe"}]` h, executed := newHexisTestHandler(t, resolved, caps) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if !*executed { t.Fatal("a capability Hexis calls safe should run") } if !actRan(reply) { t.Fatalf("unexpected reply %q", reply) } } // A mutating capability with no declared tier keeps the confirm turn it has // always had, so the split does not quietly loosen an existing box. func TestHexisUndeclaredTierStillConfirms(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) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if *executed { t.Fatal("a mutating capability ran without a confirm") } if h.pendingHexis == nil { t.Fatal("a mutating capability did not park a confirm") } if !strings.Contains(reply, "да или нет") { t.Errorf("reply = %q; want the confirm question", reply) } }