Files
Maven/cmd/mavend/ecosystem_test.go
kami 617476772e test: make the ecosystem fault suite fail when the feature is deleted
Several assertions passed against code with the behaviour removed. The
independent-outage test shared no state to begin with, the capability
fixture used to prove read-only filtering was already mutating, and
route-level faults were simulated with a separate fake instead of the
shared one. The harness now takes per-route faults and a ticking clock,
so durations are measurable and one dead endpoint can be shown not to
mute a whole service. New cases cover a resolved reference with no
entity, a rejected credential, a malformed Praxis body, foreign items
in a scoped response, named truncation, traces staying out of facts,
and enrichment making progress while its oldest batch is backed off.

Found in review of #82.
2026-08-01 14:15:33 +04:00

244 lines
8.8 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
}
// 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 || !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")
}
}