160 lines
6.6 KiB
Go
160 lines
6.6 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"), routeCandidate("restart"))
|
|
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"), routeCandidate("restart"))
|
|
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"), routeCandidate("restart"))
|
|
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"), routeCandidate("restart"))
|
|
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"), routeCandidate("restart"))
|
|
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)
|
|
}
|
|
}
|