package main import ( "context" "net/http" "strings" "testing" "time" hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/store" ) // Phase-5 hardening suite (Vikunja #276). Everything here drives the shared // fake ecosystem (fakeecosystem_test.go) rather than one-off inline handlers, // so the same fault levers — SetFault, SetBody, SetDelay — cover every // service. What is asserted is the degraded-mode contract: // // - services degrade independently: one outage never mutes the others, // - a degraded reply is never silent, never fabricated, never "success", // - contract drift (old shape, unknown fields, garbage) is survivable, // - Maven never acts on an ambiguous target and never chains // Praxis observation into Hexis execution on its own. // ecoHandler wires a handler against whichever of the three fakes is given // (pass nil to leave a service unconfigured, which is a different state from // "configured but down"). func ecoHandler(t *testing.T, nexus, praxis, hexis *fakeServer) *reactiveHandler { t.Helper() st := newTestStore(t) clock := newFakeClock(time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)) w := &ecosystemWiring{} if nexus != nil { w.nexus = newNexusClient(nexus.URL) } if praxis != nil { w.praxis = newPraxisClient(praxis.URL) } if hexis != nil { w.hexis = hexisclient.New(hexis.URL) } return &reactiveHandler{ api: ipc.NewStoreAPI(st), dataStore: st, now: clock.Now, ecosystem: w, } } func traceFacts(t *testing.T, h *reactiveHandler) []store.Fact { t.Helper() facts, err := h.dataStore.RecentFacts(context.Background(), 50) if err != nil { t.Fatalf("read facts: %v", err) } var out []store.Fact for _, f := range facts { if f.Source == "praxis:trace" { out = append(out, f) } } return out } func restartCaps() string { return fixtureHexisCapabilities(map[string]any{ "id": "cap_restart", "name": "restart", "read_only": true, }) } // TestEcosystem_OutagesAreIndependent: Praxis being down must not disable the // Nexus+Hexis action path, and vice versa. A shared "ecosystem is broken" // mode would take away working capability for no reason. func TestEcosystem_OutagesAreIndependent(t *testing.T) { ctx := context.Background() nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{ "id": "item_1", "title": "disk almost full", "importance": 3.0, })) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, praxis, hexis) praxis.SetFault(503) if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { t.Fatalf("praxis outage must not block the hexis path, got %q", reply) } praxis.SetFault(0) hexis.SetFault(503) nexus.SetFault(503) reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")) if !strings.Contains(reply, "disk almost full") { t.Fatalf("nexus/hexis outage must not block the praxis digest, got %q", reply) } } // TestEcosystem_MalformedNexusResponseFailsClosed: a 200 carrying garbage is a // dependency failure, not "no such entity". It must stop before Hexis. func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) { ctx := context.Background() nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) nexus.SetBody(`{"status":"resolved","entity":`) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if reply == "" || strings.Contains(reply, "выполнена") { t.Fatalf("malformed nexus body must degrade, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { t.Fatal("hexis must not be contacted after a malformed nexus response") } } // TestEcosystem_UnknownContractFieldsTolerated: a newer Nexus adding fields // must not break an older Maven. Same for the older flat resolve shape. func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) { ctx := context.Background() for name, body := range map[string]string{ "future": fixtureNexusResolvedFuture("ent_muzick", "Muzick indexer", "service"), "flat": fixtureNexusResolvedFlat("ent_muzick", "Muzick indexer", "service"), } { t.Run(name, func(t *testing.T) { nexus := newFakeNexus(t, body) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply) } }) } } // TestEcosystem_CancelledContextDegrades: a caller hanging up (turn abandoned, // deadline hit) must surface as degradation, never as a fabricated result. func TestEcosystem_CancelledContextDegrades(t *testing.T) { nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) nexus.SetDelay(2 * time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if reply == "" || strings.Contains(reply, "выполнена") { t.Fatalf("cancelled resolve must degrade, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { t.Fatal("hexis must not be contacted after a cancelled resolve") } } // TestEcosystem_ExecutionFailureIsNotSuccess: Hexis answering 200 with // status=failed is a partial failure — the call worked, the command did not. // Maven must report it as a failure and must not write a success trace. func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) { ctx := context.Background() nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecutionFailed("exec_1", "unit not found")) h := ecoHandler(t, nexus, nil, hexis) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) if strings.Contains(reply, "выполнена") { t.Fatalf("failed execution must not read as success, got %q", reply) } if reply == "" { t.Fatal("failed execution must say something") } for _, f := range traceFacts(t, h) { if strings.HasPrefix(f.Key, "praxis:hexis:") { t.Fatalf("failed execution must not write a success trace: %+v", f) } } } // TestEcosystem_AmbiguousTargetBlocksExecution: ambiguity blocks mutation, and // the clarification must name the candidates rather than pick one. func TestEcosystem_AmbiguousTargetBlocksExecution(t *testing.T) { ctx := context.Background() nexus := newFakeNexus(t, fixtureNexusAmbiguous( map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"}, map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"}, )) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) reply := h.handleHexisAct(ctx, actDec("muzick")) if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") { t.Fatalf("ambiguous resolve must list candidates, got %q", reply) } if hexis.Count("POST", "/api/v1/execute") != 0 { t.Fatal("ambiguous target must never execute") } } // TestEcosystem_NoAutonomousPraxisToHexis: reading the attention digest is an // observation. Maven must never turn an observed problem into a Hexis command // by herself — she is not autonomous. func TestEcosystem_NoAutonomousPraxisToHexis(t *testing.T) { ctx := context.Background() praxis := newFakePraxis(t, fixturePraxisAttentionItems( map[string]any{"id": "item_1", "title": "muzick indexer is down", "importance": 4.0, "rule": "service_down"}, )) nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, praxis, hexis) _ = h.handlePraxisAct(ctx, praxisActDec("list_attention")) if hexis.Count("", "/api/v1") != 0 { t.Fatal("attention digest must not contact hexis on its own") } if nexus.Count("", "/api/v1/resolve") != 0 { t.Fatal("attention digest must not resolve targets for autonomous action") } } // TestEcosystem_MutatingCapabilityWaitsForConfirmation: a non-read-only // capability parks for an explicit spoken confirm bound to capability+target. func TestEcosystem_MutatingCapabilityWaitsForConfirmation(t *testing.T) { ctx := context.Background() nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false}) hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) reply := h.handleHexisAct(ctx, actDec("restart")) if !strings.Contains(reply, "restart") || !strings.Contains(reply, "да") { t.Fatalf("mutating capability must ask for confirmation, got %q", reply) } if hexis.Count("POST", "/api/v1/execute") != 0 { t.Fatal("mutating capability must not execute before confirmation") } h.mu.Lock() pending := h.pendingHexis h.mu.Unlock() if pending == nil || pending.capabilityID != "cap_restart" || pending.entityID != "ent_muzick" { t.Fatalf("confirmation must be bound to capability+target, got %+v", pending) } } // TestEcosystem_SurfaceFailureStillDelivers: surfacing is bookkeeping. If the // surface call fails the digest must still be spoken — a partial failure // downgrades bookkeeping, not the answer. func TestEcosystem_SurfaceFailureStillDelivers(t *testing.T) { ctx := context.Background() praxis := newFakeServer(t, map[string]http.HandlerFunc{ "GET /api/v1/tools/attention": jsonHandler(200, fixturePraxisAttentionItems( map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0}, )), "POST /api/v1/tools/surface": jsonHandler(500, `{"error":"boom"}`), }) h := ecoHandler(t, nil, praxis, nil) reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")) if !strings.Contains(reply, "disk almost full") { t.Fatalf("failed surface must not swallow the digest, got %q", reply) } if praxis.Count("POST", "/api/v1/tools/surface") == 0 { t.Fatal("expected the surface attempt") } } // TestEcosystem_TotalOutageSaysSoForEveryPath: with all three down, every // entry point degrades explicitly instead of returning empty or inventing. func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) { ctx := context.Background() nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) praxis := newFakePraxis(t, fixturePraxisAttentionItems()) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) for _, fs := range []*fakeServer{nexus, praxis, hexis} { fs.SetFault(503) } h := ecoHandler(t, nexus, praxis, hexis) for name, reply := range map[string]string{ "hexis act": h.handleHexisAct(ctx, actDec("muzick indexer")), "attention": h.handlePraxisAct(ctx, praxisActDec("list_attention")), "changes": h.handlePraxisAct(ctx, praxisActDec("list_changes")), "acknowledge": h.handlePraxisAct(ctx, praxisActDec("acknowledge_item")), } { if reply == "" { t.Errorf("%s: total outage must not answer with silence", name) } if strings.Contains(reply, "выполнена") { t.Errorf("%s: total outage must not claim success: %q", name, reply) } } if len(traceFacts(t, h)) != 0 { t.Fatal("a total outage must not leave success traces behind") } } // TestEcosystem_RecoveryAfterOutageNeedsNoRestart: once the dependency comes // back the very next turn works — no cached failure state, no restart. func TestEcosystem_RecoveryAfterOutageNeedsNoRestart(t *testing.T) { ctx := context.Background() praxis := newFakePraxis(t, fixturePraxisAttentionItems( map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0}, )) h := ecoHandler(t, nil, praxis, nil) praxis.SetFault(503) if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") { t.Fatalf("outage must not serve content, got %q", reply) } praxis.SetFault(0) if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") { t.Fatalf("recovery must work on the next turn, got %q", reply) } }