Files
Maven/cmd/mavend/ecosystem_gap_test.go
claude 5d2fd91c06 a Hexis 401 says the token was refused, not that Hexis is down (V-587)
The vendored Hexis client is a separate implementation and returns a plain
fmt.Errorf for every status at or above 400, so errors.As for *ecosystemError
never matched, Unauthorized() was never consulted, and ecosystemGap always fell
through to the outage line. A wrong token sent him to inspect a healthy service.

hexisError classifies at Maven's boundary, since the client is vendored from
another repo and a local edit there is lost on the next re-vendor. The status
text is the only signal that survives the wrapping, so that is what it reads;
anything unrecognised stays at status 0, which is what Unreachable() means. The
correct fix is a typed error upstream carrying the code, and Maven cannot land
it unilaterally.

execHexis is the second site and it did not call ecosystemGap at all. It now
does, but only for a failure that belongs to the service. An execution that Hexis
accepted and that then failed keeps the command-level line: that is the command
failing, not Hexis degrading, and calling it an outage would be the same defect
pointed the other way. Authorization is unchanged: a 401 is still a refusal, it
is not retried and nothing proceeds on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:48:00 +04:00

160 lines
6.5 KiB
Go

package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
)
// A refused credential and an outage are different answers, and on the Hexis
// path only one of them used to be said. These tests pin the difference at both
// Hexis sites: the discovery hop and the execute hop (Vikunja #587). The Praxis
// half of the same defect is in praxis_gap_test.go.
//
// unreachableURL is a port nothing listens on, which is what "the service is
// down" looks like from inside a call: the connection is refused, no HTTP
// answer is ever produced, and ecosystemError.Unreachable() is true.
const unreachableURL = "http://127.0.0.1:1"
func denied(service, reply string) bool {
return phraser.IsA(phraser.EcoDenied, serviceVars(service), reply)
}
func down(service, reply string) bool {
return phraser.IsA(phraser.EcoDown, serviceVars(service), reply)
}
// hexisGapHandler wires a handler whose Nexus resolves cleanly and whose Hexis
// is the caller's to break. hexisURL is taken separately so a test can point it
// at a dead port.
func hexisGapHandler(t *testing.T, nexusURL, hexisURL string) *reactiveHandler {
t.Helper()
st := newTestStore(t)
now := time.Now()
return &reactiveHandler{
api: ipc.NewStoreAPI(st),
dataStore: st,
now: func() time.Time { return now },
ecosystem: stubEcosystem(nexusURL, hexisURL),
}
}
// TestHexisDiscovery401IsDeniedNotDown — the discovery hop.
//
// The vendored Hexis client returns a plain fmt.Errorf for every status at or
// above 400, so errors.As for *ecosystemError never matched and every failure
// fell through to the outage line. "Hexis is down" for a rejected token sends
// him to inspect a service that is running fine.
func TestHexisDiscovery401IsDeniedNotDown(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
h := hexisGapHandler(t, nexus.URL, hexis.URL)
hexis.SetFault(401)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !denied(serviceHexis, reply) {
t.Fatalf("401 from hexis discovery: got %q, want the denied line naming Hexis", reply)
}
if !strings.Contains(reply, serviceHexis) {
t.Errorf("reply does not name Hexis: %q", reply)
}
}
// TestHexisDiscoveryOutageIsDownNotDenied — the other half of the same fork.
// Without this the fix could pass by calling everything a refused credential.
func TestHexisDiscoveryOutageIsDownNotDenied(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
h := hexisGapHandler(t, nexus.URL, unreachableURL)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !down(serviceHexis, reply) {
t.Fatalf("connection refused from hexis: got %q, want the outage line naming Hexis", reply)
}
if denied(serviceHexis, reply) {
t.Error("an outage must not be reported as a refused credential")
}
}
// TestHexisExecute401IsDeniedNotCommandFailure — the execute hop, which did not
// consult ecosystemGap at all and named neither the service nor the cause.
func TestHexisExecute401IsDeniedNotCommandFailure(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
h := hexisGapHandler(t, nexus.URL, hexis.URL)
// Discovery stays healthy; only the execute endpoint refuses. A blanket
// fault would never reach the site under test.
hexis.SetRouteFault("/api/v1/execute", 401)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !denied(serviceHexis, reply) {
t.Fatalf("401 from hexis execute: got %q, want the denied line naming Hexis", reply)
}
}
// TestHexisExecuteOutageIsDown — same site, the other classification.
//
// Discovery and execution share one base URL, so the outage has to be scoped to
// the execute endpoint rather than to the server: it answers capabilities
// normally and drops the connection on execute, which is what the client sees
// when the far side dies mid-call. That produces no HTTP status at all, which is
// what Unreachable() means.
func TestHexisExecuteOutageIsDown(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/execute" {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
conn.Close()
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(caps))
}))
t.Cleanup(hexis.Close)
h := hexisGapHandler(t, nexus.URL, hexis.URL)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if !down(serviceHexis, reply) {
t.Fatalf("dropped connection on hexis execute: got %q, want the outage line", reply)
}
if denied(serviceHexis, reply) {
t.Error("an outage must not be reported as a refused credential")
}
}
// TestHexisExecutionFailedStaysCommandFailure — the boundary of the fix. Hexis
// answering 200 with a failed execution is the command failing, not Hexis
// degrading, and it must keep the command-level line rather than accusing a
// healthy service of being down.
func TestHexisExecutionFailedStaysCommandFailure(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
hexis := newFakeHexis(t, caps, fixtureHexisExecutionFailed("exec_1", "unit refused to start"))
h := hexisGapHandler(t, nexus.URL, hexis.URL)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if down(serviceHexis, reply) || denied(serviceHexis, reply) {
t.Fatalf("a failed execution must not be reported as an ecosystem gap, got %q", reply)
}
if !phraser.IsA(phraser.ActFailEntity, map[string]string{"name": muzickIndexer}, reply) {
t.Fatalf("want the command-failure line, got %q", reply)
}
}