Skip to content

Commit e15f6c0

Browse files
committed
test(dashboard/contract): integration test for CSRF + idempotency end-to-end
Drives transport.NewHandlerWithCSRF + dispatcher.NewWithOptions through the same wiring extension.go uses in production. Two cases: - TestSecurityE2E_CSRFRequired: a command envelope carrying a CSRF token the manager rejects must return 403 + UNAUTHENTICATED, never reaching the dispatcher. - TestSecurityE2E_IdempotencyDedup: two identical command envelopes produce byte-equal response bodies — the second is served verbatim from the idempotency cache. External package (contract_test) avoids the dashboard extension import cycle. The local idempotency.Store -> dispatcher.IdempotencyStore adapter duplicates the production one in extension.go on purpose: the dispatcher deliberately keeps its IdempotencyCached private to break a cycle, so an inline test adapter is cheaper than exporting a public constructor.
1 parent 635132b commit e15f6c0

1 file changed

Lines changed: 141 additions & 0 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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

Comments
 (0)