Fail closed on Nexus/Hexis dependency errors, accept flat resolve shape

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.
This commit is contained in:
kami
2026-07-20 01:08:11 +04:00
parent 44421d2048
commit d9fa4d6613
3 changed files with 141 additions and 9 deletions
+98
View File
@@ -140,3 +140,101 @@ func TestHexisAmbiguousAsksClarification(t *testing.T) {
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")
}
}