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) } }