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)") } }