|
| 1 | +// Package contract_test verifies the slice (b) security stack — CSRF |
| 2 | +// validation and idempotency dedup — at the seam where transport.Handler |
| 3 | +// meets dispatcher.Dispatcher. Both tests use the production |
| 4 | +// transport.NewHandlerWithCSRF + dispatcher.NewWithOptions constructors, |
| 5 | +// so a regression in either layer surfaces here. |
| 6 | +// |
| 7 | +// External package (contract_test) deliberately avoids the dashboard |
| 8 | +// extension's import cycle: this test depends on contract, dispatcher, |
| 9 | +// idempotency, transport, and security — all of which the extension also |
| 10 | +// imports — but never the other way round. |
| 11 | +package contract_test |
| 12 | + |
| 13 | +import ( |
| 14 | + "bytes" |
| 15 | + "context" |
| 16 | + "encoding/json" |
| 17 | + "net/http" |
| 18 | + "net/http/httptest" |
| 19 | + "strings" |
| 20 | + "testing" |
| 21 | + |
| 22 | + "github.com/xraph/forge/extensions/dashboard/contract" |
| 23 | + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" |
| 24 | + "github.com/xraph/forge/extensions/dashboard/contract/idempotency" |
| 25 | + "github.com/xraph/forge/extensions/dashboard/contract/transport" |
| 26 | + "github.com/xraph/forge/extensions/dashboard/security" |
| 27 | +) |
| 28 | + |
| 29 | +// TestSecurityE2E_CSRFRequired confirms that a command envelope with a CSRF |
| 30 | +// token the manager refuses returns 403 + UNAUTHENTICATED. This is the |
| 31 | +// rollout-critical path: a stale or forged token must NEVER reach the |
| 32 | +// dispatcher. |
| 33 | +func TestSecurityE2E_CSRFRequired(t *testing.T) { |
| 34 | + reg, wreg, disp := setupSecurityEnv(t, idempotency.NewInMemoryStore()) |
| 35 | + mgr := security.NewCSRFManager() |
| 36 | + h := transport.NewHandlerWithCSRF(reg, wreg, disp, contract.NoopAuditEmitter{}, mgr) |
| 37 | + |
| 38 | + body, _ := json.Marshal(contract.Request{ |
| 39 | + Envelope: "v1", Kind: contract.KindCommand, |
| 40 | + Contributor: "test", Intent: "do.thing", IntentVersion: 1, |
| 41 | + CSRF: "wrong", IdempotencyKey: "k1", |
| 42 | + }) |
| 43 | + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) |
| 44 | + w := httptest.NewRecorder() |
| 45 | + h.ServeHTTP(w, req) |
| 46 | + if w.Code != http.StatusForbidden { |
| 47 | + t.Errorf("expected 403, got %d body=%s", w.Code, w.Body) |
| 48 | + } |
| 49 | + if !strings.Contains(w.Body.String(), "UNAUTHENTICATED") { |
| 50 | + t.Errorf("expected UNAUTHENTICATED in response, got %s", w.Body.String()) |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +// TestSecurityE2E_IdempotencyDedup confirms that two identical command |
| 55 | +// envelopes — same idempotency key, same CSRF token, same payload — produce |
| 56 | +// byte-equal response bodies. The first call dispatches; the second is a |
| 57 | +// cache hit served verbatim from the idempotency store. |
| 58 | +func TestSecurityE2E_IdempotencyDedup(t *testing.T) { |
| 59 | + store := idempotency.NewInMemoryStore() |
| 60 | + reg, wreg, disp := setupSecurityEnv(t, store) |
| 61 | + mgr := security.NewCSRFManager() |
| 62 | + tok := mgr.GenerateToken() |
| 63 | + h := transport.NewHandlerWithCSRF(reg, wreg, disp, contract.NoopAuditEmitter{}, mgr) |
| 64 | + |
| 65 | + build := func() *http.Request { |
| 66 | + body, _ := json.Marshal(contract.Request{ |
| 67 | + Envelope: "v1", Kind: contract.KindCommand, |
| 68 | + Contributor: "test", Intent: "do.thing", IntentVersion: 1, |
| 69 | + CSRF: tok, IdempotencyKey: "ik_e2e", |
| 70 | + }) |
| 71 | + return httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) |
| 72 | + } |
| 73 | + w1 := httptest.NewRecorder() |
| 74 | + h.ServeHTTP(w1, build()) |
| 75 | + w2 := httptest.NewRecorder() |
| 76 | + h.ServeHTTP(w2, build()) |
| 77 | + |
| 78 | + if w1.Body.String() != w2.Body.String() { |
| 79 | + t.Errorf("idempotent calls produced different bodies:\nfirst: %s\nsecond: %s", w1.Body, w2.Body) |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +// setupSecurityEnv wires a registry containing one write-capability command |
| 84 | +// intent (`test/do.thing@1`), an empty warden registry, and a dispatcher |
| 85 | +// configured with the supplied idempotency store. The bound handler is the |
| 86 | +// minimum viable command handler — returns OK:true with no work. |
| 87 | +func setupSecurityEnv(t *testing.T, store idempotency.Store) (contract.Registry, contract.WardenRegistry, *dispatcher.Dispatcher) { |
| 88 | + t.Helper() |
| 89 | + reg := contract.NewRegistry() |
| 90 | + src := ` |
| 91 | +schemaVersion: 1 |
| 92 | +contributor: { name: test, envelope: { supports: [v1], preferred: v1 } } |
| 93 | +intents: |
| 94 | + - { name: do.thing, kind: command, version: 1, capability: write } |
| 95 | +` |
| 96 | + var m contract.ContractManifest |
| 97 | + if err := contract.UnmarshalManifestForTest([]byte(src), &m); err != nil { |
| 98 | + t.Fatal(err) |
| 99 | + } |
| 100 | + if err := reg.Register(&m); err != nil { |
| 101 | + t.Fatal(err) |
| 102 | + } |
| 103 | + wreg := contract.NewWardenRegistry() |
| 104 | + disp := dispatcher.NewWithOptions(dispatcher.NoopMetricsEmitter{}, |
| 105 | + dispatcher.WithIdempotencyStore(adaptStore(store))) |
| 106 | + if err := dispatcher.RegisterCommand(disp, "test", "do.thing", 1, |
| 107 | + func(_ context.Context, _ struct{}, _ contract.Principal) (struct{ OK bool }, error) { |
| 108 | + return struct{ OK bool }{OK: true}, nil |
| 109 | + }); err != nil { |
| 110 | + t.Fatalf("RegisterCommand: %v", err) |
| 111 | + } |
| 112 | + return reg, wreg, disp |
| 113 | +} |
| 114 | + |
| 115 | +// adapter mirrors the production idempotencyAdapter in extensions/dashboard. |
| 116 | +// The duplication is intentional: this is an external test package that |
| 117 | +// can't reach into the dashboard package without re-introducing the cycle |
| 118 | +// the contract sub-packages were carved out to avoid. The conversion is |
| 119 | +// trivial enough that keeping it inline here is cheaper than exposing a |
| 120 | +// public adapter constructor. |
| 121 | +type adapter struct{ inner idempotency.Store } |
| 122 | + |
| 123 | +func adaptStore(s idempotency.Store) dispatcher.IdempotencyStore { return &adapter{inner: s} } |
| 124 | + |
| 125 | +func (a *adapter) Lookup(ctx context.Context, k, id string) (*dispatcher.IdempotencyCached, bool) { |
| 126 | + c, ok := a.inner.Lookup(ctx, k, id) |
| 127 | + if !ok { |
| 128 | + return nil, false |
| 129 | + } |
| 130 | + return &dispatcher.IdempotencyCached{ |
| 131 | + Status: c.Status, WireBody: c.WireBody, |
| 132 | + StoredAt: c.StoredAt, TTL: c.TTL, |
| 133 | + }, true |
| 134 | +} |
| 135 | + |
| 136 | +func (a *adapter) Store(ctx context.Context, k, id string, c dispatcher.IdempotencyCached) error { |
| 137 | + return a.inner.Store(ctx, k, id, idempotency.Cached{ |
| 138 | + Status: c.Status, WireBody: c.WireBody, |
| 139 | + StoredAt: c.StoredAt, TTL: c.TTL, |
| 140 | + }) |
| 141 | +} |
0 commit comments