|
| 1 | +package angelus |
| 2 | + |
| 3 | +// THE GUARDED SET MUST EQUAL THE SERVED SET. |
| 4 | +// |
| 5 | +// Before 2026-08-25 it did not. authz.Guard wrapped the angelus map, and the |
| 6 | +// per-aria hub socket beside it served figaro.qua, figaro.set, figaro.study, |
| 7 | +// figaro.cast, figaro.drop, figaro.interrupt and the queue verbs with NO |
| 8 | +// policy consulted at all -- the entire agency surface, ungated, on a door a |
| 9 | +// gateway was about to expose. |
| 10 | +// |
| 11 | +// These tests fail if that regresses, and they fail by ENUMERATION rather |
| 12 | +// than by naming the methods they know about, so a method added later cannot |
| 13 | +// slip through by being absent from a list somebody forgot to update. |
| 14 | + |
| 15 | +import ( |
| 16 | + "context" |
| 17 | + "encoding/json" |
| 18 | + "testing" |
| 19 | + |
| 20 | + "github.com/jack-work/figaro/api/rpc" |
| 21 | + "github.com/jack-work/figaro/internal/authz" |
| 22 | + "github.com/jack-work/figaro/internal/figaro" |
| 23 | + "github.com/jack-work/jkrpc" |
| 24 | +) |
| 25 | + |
| 26 | +// TestHubHandlersAreGuarded walks every method the aria endpoint serves and |
| 27 | +// asserts the policy saw it. Enumerated from figaro.AgentMethods(), so a new |
| 28 | +// agent method is covered the moment it exists. |
| 29 | +func TestHubHandlersAreGuarded(t *testing.T) { |
| 30 | + var seen []string |
| 31 | + hb := newAriaHub("test", "") |
| 32 | + hb.guard = func(m map[string]jkrpc.HandlerFunc) map[string]jkrpc.HandlerFunc { |
| 33 | + return authz.Guard(m, authz.AriaHeader{Enabled: false}, |
| 34 | + authz.PolicyFunc(func(r authz.Request) authz.Decision { |
| 35 | + seen = append(seen, r.Method) |
| 36 | + return authz.Deny("policy consulted") |
| 37 | + })) |
| 38 | + } |
| 39 | + |
| 40 | + served := hb.handlers() |
| 41 | + methods := figaro.AgentMethods() |
| 42 | + if len(served) != len(methods) { |
| 43 | + t.Fatalf("hub serves %d methods but AgentMethods lists %d", len(served), len(methods)) |
| 44 | + } |
| 45 | + |
| 46 | + for _, m := range methods { |
| 47 | + h, ok := served[m] |
| 48 | + if !ok { |
| 49 | + t.Fatalf("%s is in AgentMethods but the hub does not serve it", m) |
| 50 | + } |
| 51 | + // The handler must refuse WITHOUT reaching route(): a guard that ran |
| 52 | + // after dispatch would have already done the thing it was meant to |
| 53 | + // prevent. hb has no wake/read/write here, so a leaked call panics or |
| 54 | + // errors differently than a policy denial. |
| 55 | + _, err := h(context.Background(), json.RawMessage(`{}`)) |
| 56 | + if err == nil { |
| 57 | + t.Fatalf("%s: denied policy still returned success", m) |
| 58 | + } |
| 59 | + var jerr *jkrpc.Error |
| 60 | + if !errorsAs(err, &jerr) || jerr.Code != authz.ErrCode { |
| 61 | + t.Fatalf("%s: want an authz refusal, got %v", m, err) |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + if len(seen) != len(methods) { |
| 66 | + t.Fatalf("policy saw %d methods, hub serves %d: %v", len(seen), len(methods), seen) |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +// TestAgencyMethodsAreOnTheGuardedDoor is the regression this whole file |
| 71 | +// exists for. These are the methods that grant AGENCY -- they run turns, |
| 72 | +// write boards, and rewire what an aria observes -- and each must be served |
| 73 | +// by the hub, which is now guarded. A method that quietly moved to an |
| 74 | +// unguarded door would pass every other test in the tree. |
| 75 | +func TestAgencyMethodsAreOnTheGuardedDoor(t *testing.T) { |
| 76 | + agency := []string{ |
| 77 | + rpc.MethodQua, |
| 78 | + rpc.MethodSet, |
| 79 | + rpc.MethodStudy, |
| 80 | + rpc.MethodDrop, |
| 81 | + rpc.MethodCast, |
| 82 | + rpc.MethodInterrupt, |
| 83 | + rpc.MethodQueueUpdate, |
| 84 | + rpc.MethodQueueDelete, |
| 85 | + } |
| 86 | + served := map[string]bool{} |
| 87 | + for _, m := range figaro.AgentMethods() { |
| 88 | + served[m] = true |
| 89 | + } |
| 90 | + for _, m := range agency { |
| 91 | + if !served[m] { |
| 92 | + t.Errorf("%s grants agency but is not on the aria endpoint: "+ |
| 93 | + "if it moved, confirm its new door is guarded", m) |
| 94 | + } |
| 95 | + } |
| 96 | +} |
| 97 | + |
| 98 | +// A nil guard serves unguarded. That is the pre-fix behaviour, kept only so |
| 99 | +// the hub can be built in tests without a policy -- and pinned here so nobody |
| 100 | +// mistakes it for the production path. hubFor always sets a guard. |
| 101 | +func TestNilGuardServesUnguarded(t *testing.T) { |
| 102 | + hb := newAriaHub("test", "") |
| 103 | + if hb.guard != nil { |
| 104 | + t.Fatal("a freshly built hub should carry no guard until hubFor sets one") |
| 105 | + } |
| 106 | + if len(hb.handlers()) != len(figaro.AgentMethods()) { |
| 107 | + t.Fatal("an unguarded hub should still serve the full method set") |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +func errorsAs(err error, target **jkrpc.Error) bool { |
| 112 | + e, ok := err.(*jkrpc.Error) |
| 113 | + if ok { |
| 114 | + *target = e |
| 115 | + } |
| 116 | + return ok |
| 117 | +} |
0 commit comments