Files
Maven/cmd/mavend/fakeecosystem_test.go
T
kami 617476772e test: make the ecosystem fault suite fail when the feature is deleted
Several assertions passed against code with the behaviour removed. The
independent-outage test shared no state to begin with, the capability
fixture used to prove read-only filtering was already mutating, and
route-level faults were simulated with a separate fake instead of the
shared one. The harness now takes per-route faults and a ticking clock,
so durations are measurable and one dead endpoint can be shown not to
mute a whole service. New cases cover a resolved reference with no
entity, a rejected credential, a malformed Praxis body, foreign items
in a scoped response, named truncation, traces staying out of facts,
and enrichment making progress while its oldest batch is backed off.

Found in review of #82.
2026-08-01 14:15:33 +04:00

352 lines
11 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// capturedRequest is a recorded inbound request against a fake ecosystem
// server, kept minimal (method/path/body) since that's what tests assert on.
type capturedRequest struct {
Method string
Path string
Query string
Body []byte
Header http.Header
}
// fakeServer is the common shell behind fakeNexus/fakePraxis/fakeHexis: an
// httptest.Server whose responses are driven by a caller-supplied route
// table, with every inbound request captured for later assertions and an
// optional fault (status code) that overrides all routing while active —
// the degraded-mode lever for these tests.
type fakeServer struct {
*httptest.Server
mu sync.Mutex
requests []capturedRequest
fault int // non-zero: every request gets this HTTP status instead of routing
routeFaults map[string]int // path prefix → status, for one endpoint failing alone
garbage string // non-empty: returned 200 verbatim instead of routing (malformed-contract lever)
delay time.Duration
}
// newFakeServer starts a server dispatching to routes keyed by "METHOD
// PATH_PREFIX" (matched with HasPrefix on path), falling back to notFound
// then to a 404 if no route matches. Route handlers are plain
// http.HandlerFuncs so tests can write arbitrary fixture responses.
func newFakeServer(t *testing.T, routes map[string]http.HandlerFunc) *fakeServer {
t.Helper()
fs := &fakeServer{}
fs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, 0)
if r.Body != nil {
buf := make([]byte, r.ContentLength)
if r.ContentLength > 0 {
n, _ := r.Body.Read(buf)
body = buf[:n]
}
}
fs.mu.Lock()
fs.requests = append(fs.requests, capturedRequest{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.RawQuery,
Body: body,
Header: r.Header.Clone(),
})
fault := fs.fault
if fault == 0 {
for prefix, status := range fs.routeFaults {
if hasPrefix(r.URL.Path, prefix) {
fault = status
break
}
}
}
garbage := fs.garbage
delay := fs.delay
fs.mu.Unlock()
if delay > 0 {
select {
case <-time.After(delay):
case <-r.Context().Done():
return
}
}
if fault != 0 {
http.Error(w, "injected fault", fault)
return
}
if garbage != "" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(garbage))
return
}
for key, handler := range routes {
method, prefix := splitRouteKey(key)
if r.Method == method && hasPrefix(r.URL.Path, prefix) {
handler(w, r)
return
}
}
http.NotFound(w, r)
}))
t.Cleanup(fs.Server.Close)
return fs
}
func splitRouteKey(key string) (method, prefix string) {
for i := 0; i < len(key); i++ {
if key[i] == ' ' {
return key[:i], key[i+1:]
}
}
return key, ""
}
func hasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
// SetFault makes every subsequent request fail closed with the given HTTP
// status, simulating the dependency being down. Pass 0 to clear it.
func (fs *fakeServer) SetFault(status int) {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.fault = status
}
// SetRouteFault fails one endpoint while the rest of the server stays healthy,
// which is the shape most real outages take: attention answers and pin is
// down. Pass 0 to clear that route. A server-wide SetFault still wins.
func (fs *fakeServer) SetRouteFault(pathPrefix string, status int) {
fs.mu.Lock()
defer fs.mu.Unlock()
if fs.routeFaults == nil {
fs.routeFaults = map[string]int{}
}
if status == 0 {
delete(fs.routeFaults, pathPrefix)
return
}
fs.routeFaults[pathPrefix] = status
}
// SetBody makes every subsequent request answer 200 with the given body,
// bypassing the route table. Used to serve a malformed or contract-violating
// payload where the transport itself is healthy. Pass "" to clear it.
func (fs *fakeServer) SetBody(body string) {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.garbage = body
}
// SetDelay stalls every subsequent request for d before answering, so callers
// can drive client timeouts and context cancellation deterministically. The
// delay is abandoned as soon as the client hangs up.
func (fs *fakeServer) SetDelay(d time.Duration) {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.delay = d
}
// Count returns how many captured requests used the given method and path
// prefix. "" matches any method.
func (fs *fakeServer) Count(method, prefix string) int {
n := 0
for _, r := range fs.Requests() {
if (method == "" || r.Method == method) && hasPrefix(r.Path, prefix) {
n++
}
}
return n
}
// Requests returns a snapshot of captured requests, in arrival order.
func (fs *fakeServer) Requests() []capturedRequest {
fs.mu.Lock()
defer fs.mu.Unlock()
out := make([]capturedRequest, len(fs.requests))
copy(out, fs.requests)
return out
}
func jsonHandler(status int, body string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write([]byte(body))
}
}
// --- Protocol fixtures ---
// Canned response bodies matching each service's documented contract shape,
// so individual tests don't hand-roll JSON literals for the common cases.
func fixtureNexusResolved(entityID, displayName, entityType string) string {
return mustJSON(map[string]any{
"status": "resolved",
"entity": map[string]any{"id": entityID, "display_name": displayName, "type": entityType},
})
}
// fixtureNexusResolvedFlat is the flat resolve shape documented in
// ECOSYSTEM-SPEC.md §1.5 (entity_id/entity_type/display_name at the top
// level) rather than the nested "entity" object — the older of the two
// wire shapes Maven must keep accepting.
func fixtureNexusResolvedFlat(entityID, displayName, entityType string) string {
return mustJSON(map[string]any{
"status": "resolved",
"entity_id": entityID,
"entity_type": entityType,
"display_name": displayName,
})
}
// fixtureNexusResolvedFuture is a resolved response from a hypothetical newer
// Nexus: same required fields plus unknown ones. Decoding must ignore the
// extras, not fail — forward compatibility is what lets the ecosystem be
// upgraded one service at a time.
func fixtureNexusResolvedFuture(entityID, displayName, entityType string) string {
return mustJSON(map[string]any{
"status": "resolved",
"entity": map[string]any{"id": entityID, "display_name": displayName, "type": entityType, "tenant": "home"},
"provenance": map[string]any{"resolver": "v3", "graph_epoch": 42},
"score_breakdown": []any{map[string]any{"signal": "alias", "weight": 0.9}},
})
}
// fixtureNexusResolvedEmpty is the contract violation that decodes cleanly:
// Nexus claims a resolve and delivers no entity. It must not read as "no such
// entity", which would let the caller fall through to local execution with the
// user's verb intact.
func fixtureNexusResolvedEmpty() string {
return `{"status":"resolved"}`
}
func fixtureNexusNotFound() string {
return `{"status":"not_found"}`
}
func fixtureNexusAmbiguous(candidates ...map[string]string) string {
cs := make([]map[string]any, len(candidates))
for i, c := range candidates {
cs[i] = map[string]any{"entity_id": c["entity_id"], "display_name": c["display_name"]}
}
return mustJSON(map[string]any{"status": "ambiguous", "candidates": cs})
}
func fixtureHexisCapabilities(caps ...map[string]any) string {
return mustJSON(caps)
}
func fixtureHexisExecuted(id, status string) string {
return mustJSON(map[string]any{"id": id, "status": status})
}
// fixtureHexisExecutionFailed is a well-formed Hexis response reporting that
// the command itself failed: the call succeeded, the execution did not. Maven
// must distinguish this from a transport failure and from success.
func fixtureHexisExecutionFailed(id, message string) string {
return mustJSON(map[string]any{"id": id, "status": "failed", "error": message})
}
// fixturePraxisAttentionScoped tags each item with an entity_id, which is what
// a Praxis that understands the entity_id query parameter returns. A Praxis
// that ignores it answers with untagged items from every entity.
func fixturePraxisAttentionScoped(entityID string, items ...map[string]any) string {
for _, item := range items {
item["entity_id"] = entityID
}
return mustJSON(items)
}
func fixturePraxisAttentionItems(items ...map[string]any) string {
return mustJSON(items)
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(b)
}
// --- Fake clock ---
// A settable time source satisfying the `func() time.Time` shape reactiveHandler
// takes as `now`, so tests can control "now" independently of wall-clock time
// (e.g. asserting age-based digest ordering without sleeping).
type fakeClock struct {
mu sync.Mutex
t time.Time
step time.Duration // advanced on every read, so elapsed time is measurable
}
func newFakeClock(start time.Time) *fakeClock {
return &fakeClock{t: start}
}
// newTickingClock advances by step on every read. Durations measured across
// hops are then non-zero without sleeping, which is what lets a test tell a
// trace that measured something from one that measured nothing.
func newTickingClock(start time.Time, step time.Duration) *fakeClock {
return &fakeClock{t: start, step: step}
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
now := c.t
c.t = c.t.Add(c.step)
return now
}
func (c *fakeClock) Advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.t = c.t.Add(d)
}
// --- Convenience constructors for the three ecosystem services ---
// newFakeNexus starts a fake Nexus exposing /api/v1/resolve. resolveBody is
// returned verbatim (200) for every resolve call unless a fault is injected.
func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
return newFakeServer(t, map[string]http.HandlerFunc{
"POST /api/v1/resolve": jsonHandler(http.StatusOK, resolveBody),
})
}
// newFakePraxis starts a fake Praxis exposing the /api/v1/tools/* surface
// Maven's praxisClient calls. Every route returns its fixed body until a
// fault is injected via SetFault.
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
return newFakeServer(t, map[string]http.HandlerFunc{
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
"POST /api/v1/tools/acknowledge": jsonHandler(http.StatusOK, `{}`),
"POST /api/v1/tools/resolve": jsonHandler(http.StatusOK, `{}`),
"POST /api/v1/tools/ignore": jsonHandler(http.StatusOK, `{}`),
"POST /api/v1/tools/pin": jsonHandler(http.StatusOK, `{}`),
})
}
// newFakeHexis starts a fake Hexis exposing /api/v1/capabilities and
// /api/v1/execute.
func newFakeHexis(t *testing.T, capsBody, executeBody string) *fakeServer {
return newFakeServer(t, map[string]http.HandlerFunc{
"GET /api/v1/capabilities": jsonHandler(http.StatusOK, capsBody),
"POST /api/v1/execute": jsonHandler(http.StatusOK, executeBody),
})
}