ZDDC/zddc/internal/policy/policy_test.go
ZDDC a01315fd00 feat(server): reference Rego, parity test, decision cache, listing ETags
Phase 2 enhancements to the policy decider, plus listing-level ETags
that benefit every deployment regardless of decider mode.

Reference Rego policy
---------------------
internal/policy/rego/access.rego mirrors InternalDecider's semantics
exactly — bottom-up walk, deny-first within a level, default-deny when
HasAnyFile=true, glob matching with @-boundary semantics (special-cased
bare "*" because OPA's glob.match treats empty delimiters
inconsistently for that pattern).

Embedded into the binary via go:embed; --print-rego dumps it to stdout
so federal customers standing up an external OPA can use it as a
parity-tested baseline:

    zddc-server --print-rego > /etc/opa/policies/zddc-access.rego

Parity test runner
------------------
parity_test.go imports the OPA Go module as a TEST-ONLY dependency
(github.com/open-policy-agent/opa@v0.70.0). Every fixture from the
internal Go evaluator's test set runs through both implementations;
any divergence fails CI. The test-only import means production
binaries (built by `go build ./cmd/zddc-server`) stay OPA-free —
release-flag binary size unchanged at ~13 MB.

The parity test caught a real bug on first run: bare "*" patterns
didn't match through OPA's glob.match with empty delimiters. Fixed
in access.rego with a special-case rule. This is exactly the kind of
subtle drift the parity guard exists to catch.

External-mode decision cache
----------------------------
HTTPDecider is now wrapped in a cachingDecider with a default 1s TTL.
Bursty patterns like .archive listings (one OPA round-trip per entry
before, one per (email, decision-input) tuple per TTL window after)
amortize cleanly. Verified: 20 identical /D/ requests produce 1 OPA
hit with cache, 40 hits without (each listing makes 2 ACL queries).

ZDDC_OPA_CACHE_TTL knob (default 1s) lets operators tune. 0 disables.
1s matches the fsnotify watcher debounce window — staleness is
bounded the same way other policy-edit propagation already is.
Internal mode unchanged; the in-process Go evaluator is already
cheaper than a cache lookup would be.

Listing ETags
-------------
GET / (project list) and GET /<dir>/ (directory listing JSON) now
carry content-hash ETag + Cache-Control: private, max-age=0,
must-revalidate. SHA-256 of the rendered JSON, truncated to 16 hex
chars (64 bits — collision risk on a listing of any realistic size
is vanishingly small).

Server-side caching deliberately not added: it would require
mtime-based invalidation, and Azure Files SMB mounts (a common
deployment substrate) don't support fsnotify reliably. The
content-hash ETag delivers the bandwidth savings (304 on identical
fetches) without depending on watcher correctness — the hash is the
actual response, so it can't lie about staleness regardless of
underlying watcher behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:46:24 -05:00

403 lines
12 KiB
Go

package policy
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// TestNew_ModeSelection: New() picks the right implementation per URL.
// External-mode URLs return a cachingDecider wrapping an HTTPDecider
// by default; CacheTTL<0 disables the wrapper.
func TestNew_ModeSelection(t *testing.T) {
cases := []struct {
url string
ttl time.Duration
wantType string
wantErr bool
}{
{"", 0, "*policy.InternalDecider", false},
{"internal", 0, "*policy.InternalDecider", false},
{"INTERNAL", 0, "*policy.InternalDecider", false},
{"http://127.0.0.1:8181", 0, "*policy.cachingDecider", false},
{"https://opa.example:8181", 0, "*policy.cachingDecider", false},
{"unix:///run/opa.sock", 0, "*policy.cachingDecider", false},
{"http://127.0.0.1:8181", -1, "*policy.HTTPDecider", false}, // cache disabled
{"ftp://nope", 0, "", true},
{"garbage", 0, "", true},
}
for _, tc := range cases {
t.Run(tc.url, func(t *testing.T) {
d, err := New(Config{URL: tc.url, CacheTTL: tc.ttl})
if tc.wantErr {
if err == nil {
t.Fatal("New() = nil error, want error")
}
return
}
if err != nil {
t.Fatalf("New() unexpected error: %v", err)
}
got := describe(d)
if got != tc.wantType {
t.Errorf("New(%q, ttl=%v) → %s, want %s", tc.url, tc.ttl, got, tc.wantType)
}
})
}
}
func describe(v interface{}) string {
switch v.(type) {
case *InternalDecider:
return "*policy.InternalDecider"
case *HTTPDecider:
return "*policy.HTTPDecider"
case *cachingDecider:
return "*policy.cachingDecider"
default:
return "unknown"
}
}
// TestInternalDecider_ParityWithAllowedWithChain: the internal
// decider returns the same answer as zddc.AllowedWithChain for
// every documented cascade scenario.
func TestInternalDecider_ParityWithAllowedWithChain(t *testing.T) {
allow := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: p}} }
deny := func(p ...string) zddc.ZddcFile { return zddc.ZddcFile{ACL: zddc.ACLRules{Deny: p}} }
allowDeny := func(a, d []string) zddc.ZddcFile {
return zddc.ZddcFile{ACL: zddc.ACLRules{Allow: a, Deny: d}}
}
empty := zddc.ZddcFile{}
cases := []struct {
name string
chain zddc.PolicyChain
email string
want bool
}{
{
"empty chain, no files → allow",
zddc.PolicyChain{HasAnyFile: false},
"alice@example.com",
true,
},
{
"files exist but no rule matches → deny",
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
"alice@example.com",
false,
},
{
"leaf allow wins",
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"leaf deny beats parent allow (bottom-up first match)",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
deny("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
false,
},
{
"leaf has no rule for user, falls back to parent allow",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
allow("bob@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"leaf allows user that parent denies",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
{
"multi-level: deepest match wins",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
allowDeny([]string{"*@example.com"}, []string{"alice@example.com"}),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
},
}
d := &InternalDecider{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := AllowFromChain(context.Background(), d, tc.chain, tc.email, "/test")
if err != nil {
t.Fatalf("AllowFromChain: %v", err)
}
want := zddc.AllowedWithChain(tc.chain, tc.email)
if got != want {
t.Errorf("decider = %v, AllowedWithChain = %v (parity broken)", got, want)
}
if got != tc.want {
t.Errorf("decider = %v, want %v", got, tc.want)
}
})
}
}
// TestHTTPDecider_HappyPath: the HTTP decider posts the canonical
// OPA shape and acts on a 200 with {"result": true|false}.
func TestHTTPDecider_HappyPath(t *testing.T) {
var captured struct {
path string
contentType string
body []byte
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured.path = r.URL.Path
captured.contentType = r.Header.Get("Content-Type")
captured.body, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
in.PolicyChain = &SerializableChain{HasAnyFile: true}
got, err := d.Allow(context.Background(), in)
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != true {
t.Errorf("Allow = false, want true")
}
if captured.path != "/v1/data/zddc/access/allow" {
t.Errorf("path = %q, want /v1/data/zddc/access/allow", captured.path)
}
if !strings.HasPrefix(captured.contentType, "application/json") {
t.Errorf("content-type = %q, want application/json", captured.contentType)
}
// Body should be {"input": {...}}
var wrap struct{ Input AllowInput }
if err := json.Unmarshal(captured.body, &wrap); err != nil {
t.Fatalf("body did not unmarshal as {input}: %v (body=%s)", err, captured.body)
}
if wrap.Input.User.Email != "alice@example.com" {
t.Errorf("body.input.user.email = %q, want alice@example.com", wrap.Input.User.Email)
}
}
// TestHTTPDecider_FailClosed: any transport/encoding/HTTP error
// returns deny (false) by default, with no Go error returned to
// the caller (handlers don't have to special-case it).
func TestHTTPDecider_FailClosed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != false {
t.Errorf("on 500, Allow = true, want false (fail-closed default)")
}
}
// TestHTTPDecider_FailOpen: with FailOpen=true, a transport error
// returns allow.
func TestHTTPDecider_FailOpen(t *testing.T) {
d, err := New(Config{URL: "http://127.0.0.1:1", FailOpen: true})
if err != nil {
t.Fatalf("New: %v", err)
}
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != true {
t.Errorf("on unreachable host with FailOpen, Allow = false, want true")
}
}
// TestCachingDecider_AmortizesRoundTrips: a HTTPDecider wrapped in
// the default 1s cache only round-trips once for a burst of identical
// queries. Verifies the listing-amortization benefit for external mode.
func TestCachingDecider_AmortizesRoundTrips(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL}) // CacheTTL=0 → default 1s
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
// 50 identical calls → exactly 1 round-trip thanks to the cache.
for i := 0; i < 50; i++ {
got, err := d.Allow(context.Background(), in)
if err != nil {
t.Fatalf("Allow #%d: %v", i, err)
}
if !got {
t.Errorf("Allow #%d = false, want true", i)
}
}
if hits != 1 {
t.Errorf("OPA round-trips = %d, want 1 (cache miss-and-fill)", hits)
}
}
// TestCachingDecider_DifferentInputsSeparatelyKeyed: changing email or
// path produces a separate cache entry; a different-decision answer is
// not masked by the cached one.
func TestCachingDecider_DifferentInputsSeparatelyKeyed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var wrap struct {
Input AllowInput `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&wrap)
// Allow only "alice"; deny everyone else.
allow := wrap.Input.User.Email == "alice@example.com"
resp, _ := json.Marshal(map[string]bool{"result": allow})
_, _ = w.Write(resp)
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
for _, tc := range []struct {
email string
want bool
}{
{"alice@example.com", true},
{"bob@example.com", false},
{"alice@example.com", true}, // cached
{"bob@example.com", false}, // cached
{"carol@example.com", false}, // new
} {
in := AllowInput{Path: "/p"}
in.User.Email = tc.email
got, err := d.Allow(context.Background(), in)
if err != nil {
t.Fatalf("Allow(%s): %v", tc.email, err)
}
if got != tc.want {
t.Errorf("Allow(%s) = %v, want %v", tc.email, got, tc.want)
}
}
}
// TestCachingDecider_TTLExpires: a cached decision is re-fetched after
// the TTL window. Using a very short TTL (10ms) so the test runs fast.
func TestCachingDecider_TTLExpires(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL, CacheTTL: 10 * 1000000}) // 10ms in ns
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
if _, err := d.Allow(context.Background(), in); err != nil {
t.Fatal(err)
}
if _, err := d.Allow(context.Background(), in); err != nil {
t.Fatal(err)
}
// Two calls so far; the second is a cache hit. hits==1.
if hits != 1 {
t.Errorf("after 2 calls within TTL, hits=%d, want 1", hits)
}
// Wait past the TTL.
time.Sleep(20 * 1000000) // 20ms
if _, err := d.Allow(context.Background(), in); err != nil {
t.Fatal(err)
}
if hits != 2 {
t.Errorf("after TTL expiry, hits=%d, want 2 (re-fetched)", hits)
}
}
// TestCachingDecider_NegativeTTLDisablesCache: CacheTTL<0 returns the
// inner decider unwrapped, useful for tests that want predictable
// per-call HTTP traffic.
func TestCachingDecider_NegativeTTLDisablesCache(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(`{"result": true}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL, CacheTTL: -1})
if err != nil {
t.Fatalf("New: %v", err)
}
in := AllowInput{Path: "/p"}
in.User.Email = "alice@example.com"
for i := 0; i < 5; i++ {
_, _ = d.Allow(context.Background(), in)
}
if hits != 5 {
t.Errorf("with cache disabled, hits=%d, want 5 (one per Allow)", hits)
}
}
// TestHTTPDecider_MalformedResponse: a 200 with a missing/garbage
// result field also fails closed.
func TestHTTPDecider_MalformedResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"unexpected": "shape"}`))
}))
defer srv.Close()
d, err := New(Config{URL: srv.URL})
if err != nil {
t.Fatalf("New: %v", err)
}
got, err := d.Allow(context.Background(), AllowInput{Path: "/p"})
if err != nil {
t.Fatalf("Allow: %v", err)
}
if got != false {
t.Errorf("on missing result, Allow = true, want false (fail-closed)")
}
}