This is an automated email from the ASF dual-hosted git repository.

Similarityoung pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git


The following commit(s) were added to refs/heads/develop by this push:
     new e43f7fa1e add: Write unit tests (#947)
e43f7fa1e is described below

commit e43f7fa1e17c6b0bb2f6dcd3049217cb6f086dce
Author: twotwotwo <[email protected]>
AuthorDate: Sun Jul 26 13:33:28 2026 +0800

    add: Write unit tests (#947)
    
    * Refactor RouteSnapshot and MethodAllowed function
    
    * Enhance router logic for method-specific matching
    
    Refactor routing logic to improve method-specific trie matching and error 
handling.
    
    * Update router.go
    
    * 修正笔误
    
    * fix a typo
    
    * Add files via upload
    
    * Add files via upload
    
    * Add files via upload
    
    * Add files via upload
    
    * Add files via upload
    
    * Add files via upload
    
    * Add OPA end-to-end test runner script
    
    This script is a convenience wrapper for running end-to-end tests using Go. 
It sets up the necessary environment and executes the test suite for OPA.
    
    * Add files via upload
    
    * Refactor README.md for clarity and conciseness
    
    Updated README.md to remove redundant explanations and clarify the purpose 
of the directory. Adjusted formatting and improved section headings.
    
    * Add files via upload
    
    * Update README to include language options
    
    Add language options to the README file.
    
    * Refactor comments in e2e_opa_test.go
    
    Updated comments for clarity and consistency in e2e_opa_test.go.
    
    * fix ci fail
    
    * fix ci fail
    
    * 优化结构
    
    * fix opa tests after review
    
    * stabilize initialize opa mock transport
    
    * Make OPA run script executable
    
    * Fix OPA E2E docs table row
---
 admin/config/config_test.go         | 147 ++++++++
 admin/controller/opa/opa_test.go    |  94 ++++++
 admin/initialize/E2E_OPA.md         |  71 ++++
 admin/initialize/E2E_OPA_CN.md      |  69 ++++
 admin/initialize/e2e_opa_test.go    | 646 ++++++++++++++++++++++++++++++++++++
 admin/initialize/opa_http_test.go   |  58 ++++
 admin/initialize/router_opa_test.go | 402 ++++++++++++++++++++++
 admin/initialize/run.sh             |  46 +++
 admin/logic/opa_test.go             | 290 ++++++++++++++++
 9 files changed, 1823 insertions(+)

diff --git a/admin/config/config_test.go b/admin/config/config_test.go
new file mode 100644
index 000000000..5b57164df
--- /dev/null
+++ b/admin/config/config_test.go
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package config
+
+import (
+       "os"
+       "path/filepath"
+       "testing"
+       "time"
+)
+
+// writeYAML writes content to a .yaml file in a fresh tempdir and returns the 
path.
+func writeYAML(t *testing.T, content string) string {
+       t.Helper()
+       dir := t.TempDir()
+       path := filepath.Join(dir, "admin.yaml")
+       if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+               t.Fatalf("write yaml: %v", err)
+       }
+       return path
+}
+
+// restoreBootstrap resets the global Bootstrap pointer after each test so
+// tests don't leak state into one another.
+func restoreBootstrap(t *testing.T) {
+       t.Helper()
+       prev := Bootstrap
+       t.Cleanup(func() { Bootstrap = prev })
+}
+
+// TestLoadAPIConfigFromFile_OPA exercises the real admin startup 
config-loading
+// path used in cmd/admin/admin.go:55 — same function, same yaml library, same
+// global var. Asserts that an `opa:` section is parsed into OPAConfig and that
+// `request_timeout: 3s` deserializes to a time.Duration of 3 seconds.
+func TestLoadAPIConfigFromFile_OPA(t *testing.T) {
+       restoreBootstrap(t)
+
+       path := writeYAML(t, `
+server:
+  address: 127.0.0.1:18091
+etcd:
+  address: 127.0.0.1:2379
+  path: /pixiu/config/api/test
+mysql:
+  username: root
+  password: x
+  host: 127.0.0.1
+  port: "3306"
+  dbname: pixiu
+opa:
+  server_url: http://127.0.0.1:18181
+  policy_id: e2e-policy
+  request_timeout: 3s
+`)
+
+       b, err := LoadAPIConfigFromFile(path)
+       if err != nil {
+               t.Fatalf("LoadAPIConfigFromFile: %v", err)
+       }
+       if b == nil {
+               t.Fatal("returned bootstrap is nil")
+       }
+       if Bootstrap != b {
+               t.Fatal("global Bootstrap pointer was not updated")
+       }
+
+       if b.OPA.ServerURL != "http://127.0.0.1:18181"; {
+               t.Errorf("ServerURL: got %q", b.OPA.ServerURL)
+       }
+       if b.OPA.PolicyID != "e2e-policy" {
+               t.Errorf("PolicyID: got %q", b.OPA.PolicyID)
+       }
+       if b.OPA.RequestTimeout != 3*time.Second {
+               t.Errorf("RequestTimeout: want 3s, got %v (ns=%d)",
+                       b.OPA.RequestTimeout, 
b.OPA.RequestTimeout.Nanoseconds())
+       }
+}
+
+// TestLoadAPIConfigFromFile_OPADurationFormats locks in the duration formats
+// the YAML loader (gopkg.in/yaml.v3) accepts via time.Duration.UnmarshalText.
+// Catches regressions if the loader is swapped for one that doesn't support
+// Go duration strings.
+func TestLoadAPIConfigFromFile_OPADurationFormats(t *testing.T) {
+       cases := []struct {
+               yamlValue string
+               want      time.Duration
+       }{
+               {"3s", 3 * time.Second},
+               {"500ms", 500 * time.Millisecond},
+               {"1m30s", 90 * time.Second},
+               {"2h", 2 * time.Hour},
+       }
+       for _, c := range cases {
+               t.Run(c.yamlValue, func(t *testing.T) {
+                       restoreBootstrap(t)
+                       path := writeYAML(t, "opa:\n  request_timeout: 
"+c.yamlValue+"\n")
+                       b, err := LoadAPIConfigFromFile(path)
+                       if err != nil {
+                               t.Fatalf("load: %v", err)
+                       }
+                       if b.OPA.RequestTimeout != c.want {
+                               t.Fatalf("want %v, got %v", c.want, 
b.OPA.RequestTimeout)
+                       }
+               })
+       }
+}
+
+// TestLoadAPIConfigFromFile_NoOPASection ensures admin still loads when the
+// operator omits `opa:` entirely (back-compat); fields take Go zero values
+// and downstream code falls back to DefaultOPA* constants.
+func TestLoadAPIConfigFromFile_NoOPASection(t *testing.T) {
+       restoreBootstrap(t)
+       path := writeYAML(t, `
+server:
+  address: 127.0.0.1:18091
+`)
+       b, err := LoadAPIConfigFromFile(path)
+       if err != nil {
+               t.Fatalf("load: %v", err)
+       }
+       if b.OPA.ServerURL != "" || b.OPA.PolicyID != "" || 
b.OPA.RequestTimeout != 0 {
+               t.Errorf("expected zero OPAConfig when section omitted, got 
%+v", b.OPA)
+       }
+}
+
+// TestLoadAPIConfigFromFile_MissingPath surfaces the explicit error message —
+// guards against a refactor that silently swallows misconfiguration.
+func TestLoadAPIConfigFromFile_MissingPath(t *testing.T) {
+       if _, err := LoadAPIConfigFromFile(""); err == nil {
+               t.Fatal("expected error for empty path, got nil")
+       }
+}
diff --git a/admin/controller/opa/opa_test.go b/admin/controller/opa/opa_test.go
new file mode 100644
index 000000000..755100dc9
--- /dev/null
+++ b/admin/controller/opa/opa_test.go
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opa
+
+import (
+       "testing"
+)
+
+import (
+       adminconfig "github.com/apache/dubbo-go-pixiu/admin/config"
+)
+
+func setBootstrap(t *testing.T, b *adminconfig.AdminBootstrap) {
+       t.Helper()
+       prev := adminconfig.Bootstrap
+       adminconfig.Bootstrap = b
+       t.Cleanup(func() { adminconfig.Bootstrap = prev })
+}
+
+func TestResolveOPAServerURL_Precedence(t *testing.T) {
+       t.Run("nil bootstrap falls back to default", func(t *testing.T) {
+               setBootstrap(t, nil)
+               if got := resolveOPAServerURL(""); got != 
adminconfig.DefaultOPAServerURL {
+                       t.Fatalf("want default %q, got %q", 
adminconfig.DefaultOPAServerURL, got)
+               }
+       })
+
+       t.Run("empty bootstrap field falls back to default", func(t *testing.T) 
{
+               setBootstrap(t, &adminconfig.AdminBootstrap{})
+               if got := resolveOPAServerURL(""); got != 
adminconfig.DefaultOPAServerURL {
+                       t.Fatalf("want default %q, got %q", 
adminconfig.DefaultOPAServerURL, got)
+               }
+       })
+
+       t.Run("bootstrap value used when caller passes empty", func(t 
*testing.T) {
+               setBootstrap(t, &adminconfig.AdminBootstrap{
+                       OPA: adminconfig.OPAConfig{ServerURL: 
"http://configured:1234"},
+               })
+               if got := resolveOPAServerURL(""); got != 
"http://configured:1234"; {
+                       t.Fatalf("want bootstrap value, got %q", got)
+               }
+       })
+
+       t.Run("caller value overrides bootstrap and gets trimmed", func(t 
*testing.T) {
+               setBootstrap(t, &adminconfig.AdminBootstrap{
+                       OPA: adminconfig.OPAConfig{ServerURL: 
"http://configured:1234"},
+               })
+               if got := resolveOPAServerURL("  http://override:9999  "); got 
!= "http://override:9999"; {
+                       t.Fatalf("caller should override bootstrap, got %q", 
got)
+               }
+       })
+}
+
+func TestResolveOPAPolicyID_Precedence(t *testing.T) {
+       t.Run("nil bootstrap falls back to default", func(t *testing.T) {
+               setBootstrap(t, nil)
+               if got := resolveOPAPolicyID(""); got != 
adminconfig.DefaultOPAPolicyID {
+                       t.Fatalf("want default %q, got %q", 
adminconfig.DefaultOPAPolicyID, got)
+               }
+       })
+
+       t.Run("bootstrap value used when caller passes empty", func(t 
*testing.T) {
+               setBootstrap(t, &adminconfig.AdminBootstrap{
+                       OPA: adminconfig.OPAConfig{PolicyID: "from-config"},
+               })
+               if got := resolveOPAPolicyID(""); got != "from-config" {
+                       t.Fatalf("want bootstrap value, got %q", got)
+               }
+       })
+
+       t.Run("caller value overrides bootstrap", func(t *testing.T) {
+               setBootstrap(t, &adminconfig.AdminBootstrap{
+                       OPA: adminconfig.OPAConfig{PolicyID: "from-config"},
+               })
+               if got := resolveOPAPolicyID("override-id"); got != 
"override-id" {
+                       t.Fatalf("caller should override bootstrap, got %q", 
got)
+               }
+       })
+}
diff --git a/admin/initialize/E2E_OPA.md b/admin/initialize/E2E_OPA.md
new file mode 100644
index 000000000..37b55caba
--- /dev/null
+++ b/admin/initialize/E2E_OPA.md
@@ -0,0 +1,71 @@
+# Admin OPA → Gateway OPA Full-Link E2E
+
+**English** | [中文](E2E_OPA_CN.md)
+
+## What the suite verifies
+
+The harness stands up:
+
+1. A **smart in-process OPA mock** (`regoMockOPA`) — uses the real
+   `github.com/open-policy-agent/opa/rego` library to compile and evaluate
+   modules, so policy semantics in tests match what a real OPA daemon would
+   do. No docker, no etcd, no real OPA binary required.
+2. **The real admin Gin router** via `initialize.Routers()` with
+   `adminconfig.Bootstrap.OPA.ServerURL` pointed at the mock.
+3. **The real gateway OPA filter** from `pkg/filter/opa` (via the public
+   `Plugin.CreateFilterFactory()` API) pointed at the same mock URL.
+
+Each test publishes a policy through the admin REST PUT, then drives one or
+more HTTP requests through the gateway filter and asserts the decision.
+
+| Test | Scenario | What it proves |
+|---|---|---|
+| `TestE2E_AllowedThroughFullChain` | PUT "allow if GET" → GET request | Admin 
→ OPA → gateway end-to-end allow returns `filter.Continue` with no local reply |
+| `TestE2E_DeniedThroughFullChain` | Same policy, POST request | Deny returns 
`filter.Stop` + 403, short-circuits before upstream |
+| `TestE2E_DefaultDenyForAllRequests` | `default allow := false` only | 
5-method matrix all denied (no rule shape can sneak past) |
+| `TestE2E_PolicyHotReload` | PUT v1, then PUT v2 (no restart) | Behaviour 
flips on the very next request — the headline OPA-server-mode value |
+| `TestE2E_DeleteCausesMissingResultFailClosed` | PUT then DELETE | Gateway 
returns 502 (matches `test_opa.md` §6.6) |
+| `TestE2E_HeaderBasedAllowDeny` | Policy on `input.headers["X-Role"]` | 
Title-cased header propagation works (admin/user/missing variants) |
+| `TestE2E_GatewayTimeoutFailClosed` | 200ms decision delay, 50ms gateway 
timeout | Returns 504, elapsed time bounded under 180ms |
+| `TestE2E_PolicyIDOverrideRoutesThroughGateway` | PUT with form-level 
`policy_id` override | Override is stored under the requested policy ID and 
routed through the gateway decision path |
+
+## Running
+
+```bash
+# Default — all PR2 cases, no -v
+./admin/initialize/run.sh
+
+# Verbose
+VERBOSE=1 ./admin/initialize/run.sh
+
+# Subset by name
+./admin/initialize/run.sh -run AllowedThroughFullChain
+
+# Or directly:
+go test -count=1 -run TestE2E_ -v ./admin/initialize/
+```
+
+Expected output (verbose):
+
+```
+=== RUN   TestE2E_AllowedThroughFullChain
+--- PASS: TestE2E_AllowedThroughFullChain (0.03s)
+=== RUN   TestE2E_DeniedThroughFullChain
+--- PASS: TestE2E_DeniedThroughFullChain (0.01s)
+=== RUN   TestE2E_DefaultDenyForAllRequests
+--- PASS: TestE2E_DefaultDenyForAllRequests (0.01s)
+=== RUN   TestE2E_PolicyHotReload
+--- PASS: TestE2E_PolicyHotReload (0.01s)
+=== RUN   TestE2E_DeleteCausesMissingResultFailClosed
+--- PASS: TestE2E_DeleteCausesMissingResultFailClosed (0.01s)
+=== RUN   TestE2E_HeaderBasedAllowDeny
+--- PASS: TestE2E_HeaderBasedAllowDeny (0.01s)
+=== RUN   TestE2E_GatewayTimeoutFailClosed
+--- PASS: TestE2E_GatewayTimeoutFailClosed (0.21s)
+=== RUN   TestE2E_PolicyIDOverrideRoutesThroughGateway
+--- PASS: TestE2E_PolicyIDOverrideRoutesThroughGateway (0.01s)
+PASS
+ok      github.com/apache/dubbo-go-pixiu/admin/initialize       0.342s
+```
+
+
diff --git a/admin/initialize/E2E_OPA_CN.md b/admin/initialize/E2E_OPA_CN.md
new file mode 100644
index 000000000..ec22b2395
--- /dev/null
+++ b/admin/initialize/E2E_OPA_CN.md
@@ -0,0 +1,69 @@
+# PR2:Admin OPA → Gateway OPA 全链路 E2E 测试
+
+[English](E2E_OPA.md) | **中文**
+
+## 测试套件验证的内容
+
+该测试框架启动了:
+
+1. 一个**进程内的智能 OPA mock**(`regoMockOPA`)—— 使用真实的
+   `github.com/open-policy-agent/opa/rego` 库来编译和评估模块,所以测试中的
+   策略语义和真实 OPA 守护进程的行为是一致的。无需 docker、etcd 或真实的
+   OPA 二进制。
+2. **真实的 admin Gin 路由**,通过 `initialize.Routers()` 启动,并将
+   `adminconfig.Bootstrap.OPA.ServerURL` 指向该 mock。
+3. **真实的 gateway OPA filter**,来自 `pkg/filter/opa`(通过对外暴露的
+   `Plugin.CreateFilterFactory()` API),同样指向该 mock URL。
+
+每个测试用例先通过 admin REST PUT 下发一条策略,然后通过 gateway filter
+驱动一个或多个 HTTP 请求,并对决策结果做断言。
+
+| 测试用例 | 场景 | 验证内容 |
+|---|---|---|
+| `TestE2E_AllowedThroughFullChain` | PUT "allow if GET" → GET 请求 | Admin → 
OPA → gateway 端到端 allow 返回 `filter.Continue`,无本地应答 |
+| `TestE2E_DeniedThroughFullChain` | 同策略,POST 请求 | Deny 返回 `filter.Stop` + 
403,在到达上游之前短路 |
+| `TestE2E_DefaultDenyForAllRequests` | 仅 `default allow := false` | 5 种 HTTP 
方法全部被拒绝(任何形状的规则都无法绕过) |
+| `TestE2E_PolicyHotReload` | PUT v1,再 PUT v2(无需重启) | 行为在下一个请求上立刻翻转 —— 这是 OPA 
server 模式最核心的价值 |
+| `TestE2E_DeleteCausesMissingResultFailClosed` | 先 PUT 再 DELETE | Gateway 返回 
502(与 `test_opa.md` §6.6 一致) |
+| `TestE2E_HeaderBasedAllowDeny` | 基于 `input.headers["X-Role"]` 的策略 | 首字母大写的 
header 传递正常(admin / user / 缺失 三种场景) |
+| `TestE2E_GatewayTimeoutFailClosed` | 决策延迟 200ms,gateway 超时 50ms | 返回 
504,耗时被控制在 180ms 以内 |
+| `TestE2E_PolicyIDOverrideRoutesThroughGateway` | PUT 时使用 form 字段级别的 
`policy_id` 覆写 | 覆写后的 policy ID 会被写入 OPA,并可通过 gateway 决策路径命中 |
+
+## 运行方式
+
+```bash
+# 默认 —— 运行全部 PR2 用例,非 verbose
+./admin/initialize/run.sh
+
+# 详细输出
+VERBOSE=1 ./admin/initialize/run.sh
+
+# 按名称筛选用例
+./admin/initialize/run.sh -run AllowedThroughFullChain
+
+# 或直接运行:
+go test -count=1 -run TestE2E_ -v ./admin/initialize/
+```
+
+预期输出(verbose 模式):
+
+```
+=== RUN   TestE2E_AllowedThroughFullChain
+--- PASS: TestE2E_AllowedThroughFullChain (0.03s)
+=== RUN   TestE2E_DeniedThroughFullChain
+--- PASS: TestE2E_DeniedThroughFullChain (0.01s)
+=== RUN   TestE2E_DefaultDenyForAllRequests
+--- PASS: TestE2E_DefaultDenyForAllRequests (0.01s)
+=== RUN   TestE2E_PolicyHotReload
+--- PASS: TestE2E_PolicyHotReload (0.01s)
+=== RUN   TestE2E_DeleteCausesMissingResultFailClosed
+--- PASS: TestE2E_DeleteCausesMissingResultFailClosed (0.01s)
+=== RUN   TestE2E_HeaderBasedAllowDeny
+--- PASS: TestE2E_HeaderBasedAllowDeny (0.01s)
+=== RUN   TestE2E_GatewayTimeoutFailClosed
+--- PASS: TestE2E_GatewayTimeoutFailClosed (0.21s)
+=== RUN   TestE2E_PolicyIDOverrideRoutesThroughGateway
+--- PASS: TestE2E_PolicyIDOverrideRoutesThroughGateway (0.01s)
+PASS
+ok      github.com/apache/dubbo-go-pixiu/admin/initialize       0.342s
+```
diff --git a/admin/initialize/e2e_opa_test.go b/admin/initialize/e2e_opa_test.go
new file mode 100644
index 000000000..2eceaee0b
--- /dev/null
+++ b/admin/initialize/e2e_opa_test.go
@@ -0,0 +1,646 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package initialize
+
+import (
+       "context"
+       "encoding/json"
+       "io"
+       "net/http"
+       "net/http/httptest"
+       "net/url"
+       "strings"
+       "sync"
+       "testing"
+       "time"
+)
+
+import (
+       "github.com/gin-gonic/gin"
+
+       "github.com/open-policy-agent/opa/rego"
+)
+
+import (
+       adminconfig "github.com/apache/dubbo-go-pixiu/admin/config"
+       "github.com/apache/dubbo-go-pixiu/pkg/common/extension/filter"
+       contextHttp "github.com/apache/dubbo-go-pixiu/pkg/context/http"
+       opaFilter "github.com/apache/dubbo-go-pixiu/pkg/filter/opa"
+)
+
+// This file is the PR2 deliverable: a CI-runnable end-to-end test that 
exercises
+// the full admin → OPA Server → gateway filter chain in a single process.
+//
+//     admin REST PUT /config/api/opa/policy
+//          → JWT auth → controller → logic → HTTP PUT /v1/policies/<id>
+//              ↓
+//          regoMockOPA (httptest) — stores rego module text AND compiles it
+//              ↑
+//     gateway OPA filter POST {server_url}/v1/data/<path>
+//          ← input(method, path, headers, ...) → rego evaluation
+//          → filter.Continue (allow) | filter.Stop+403 (deny)
+//
+// The mock OPA is "smart" — it uses the real github.com/open-policy-agent/opa
+// rego library that pkg/filter/opa already depends on, so the policy
+// evaluation in the test is identical to what a real OPA server would do.
+// No docker, no etcd, no external process needed.
+// ---------------------------------------------------------------------------
+// regoMockOPA: an in-process OPA server that speaks the subset of the OPA REST
+// API exercised by the admin + gateway: PUT/GET/DELETE /v1/policies/{id} and
+// POST /v1/data/<any/path>.
+// ---------------------------------------------------------------------------
+type regoMockOPA struct {
+       srv *httptest.Server
+
+       mu       sync.Mutex
+       policies map[string]string // policy_id -> rego module text
+
+       // putRequests records every PUT for assertions (auth header, body, 
etc.).
+       putRequests []recordedRequest
+
+       // decisionDelay, if set, makes the POST /v1/data/... handler sleep 
before
+       // evaluating. Lets tests exercise gateway-side timeouts.
+       decisionDelay time.Duration
+}
+
+func newRegoMockOPA(t *testing.T) *regoMockOPA {
+       t.Helper()
+       useDirectHTTPTransport(t)
+       m := &regoMockOPA{policies: map[string]string{}}
+       m.srv = startLoopbackHTTPServer(t, http.HandlerFunc(m.handle))
+       return m
+}
+
+func (m *regoMockOPA) URL() string { return m.srv.URL }
+
+func (m *regoMockOPA) handle(w http.ResponseWriter, r *http.Request) {
+       switch {
+       case strings.HasPrefix(r.URL.Path, "/v1/policies/"):
+               m.handlePolicy(w, r)
+       case strings.HasPrefix(r.URL.Path, "/v1/data/"):
+               m.handleDecision(w, r)
+       default:
+               w.WriteHeader(http.StatusNotFound)
+       }
+}
+
+func (m *regoMockOPA) handlePolicy(w http.ResponseWriter, r *http.Request) {
+       id := strings.TrimPrefix(r.URL.Path, "/v1/policies/")
+       body, _ := io.ReadAll(r.Body)
+
+       switch r.Method {
+       case http.MethodPut:
+               // Sanity-check the rego compiles before accepting; mirrors 
real OPA's
+               // behavior of returning 400 with a compile error transcript.
+               if _, err := rego.New(
+                       rego.Query("data"),
+                       rego.Module(id, string(body)),
+               ).PrepareForEval(context.Background()); err != nil {
+                       w.WriteHeader(http.StatusBadRequest)
+                       _, _ = w.Write([]byte(err.Error()))
+                       return
+               }
+               m.mu.Lock()
+               m.policies[id] = string(body)
+               m.putRequests = append(m.putRequests, recordedRequest{
+                       method: r.Method,
+                       path:   r.URL.Path,
+                       ctype:  r.Header.Get("Content-Type"),
+                       auth:   r.Header.Get("Authorization"),
+                       body:   string(body),
+               })
+               m.mu.Unlock()
+               w.WriteHeader(http.StatusOK)
+               _, _ = w.Write([]byte("{}"))
+
+       case http.MethodGet:
+               m.mu.Lock()
+               raw, ok := m.policies[id]
+               m.mu.Unlock()
+               if !ok {
+                       w.WriteHeader(http.StatusNotFound)
+                       return
+               }
+               w.Header().Set("Content-Type", "application/json")
+               _ = json.NewEncoder(w).Encode(map[string]any{
+                       "result": map[string]any{"id": id, "raw": raw},
+               })
+
+       case http.MethodDelete:
+               m.mu.Lock()
+               delete(m.policies, id)
+               m.mu.Unlock()
+               w.WriteHeader(http.StatusOK)
+
+       default:
+               w.WriteHeader(http.StatusMethodNotAllowed)
+       }
+}
+
+// handleDecision implements POST /v1/data/<rule path>. It bundles every stored
+// policy into a single rego module set, runs the query, and replies with
+// {"result": <value>} — matching the OPA REST contract that
+// pkg/filter/opa/opa.go expects in evaluateServer().
+func (m *regoMockOPA) handleDecision(w http.ResponseWriter, r *http.Request) {
+       if r.Method != http.MethodPost {
+               w.WriteHeader(http.StatusMethodNotAllowed)
+               return
+       }
+
+       if m.decisionDelay > 0 {
+               time.Sleep(m.decisionDelay)
+       }
+
+       var reqBody map[string]any
+       if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+               w.WriteHeader(http.StatusBadRequest)
+               return
+       }
+       input := reqBody["input"]
+
+       // /v1/data/http/authz/allow → data.http.authz.allow
+       rulePath := strings.TrimPrefix(r.URL.Path, "/v1/data/")
+       query := "data." + strings.ReplaceAll(rulePath, "/", ".")
+
+       m.mu.Lock()
+       modules := make(map[string]string, len(m.policies))
+       for id, raw := range m.policies {
+               modules[id] = raw
+       }
+       m.mu.Unlock()
+
+       opts := []func(r *rego.Rego){rego.Query(query)}
+       for id, raw := range modules {
+               opts = append(opts, rego.Module(id, raw))
+       }
+       pq, err := rego.New(opts...).PrepareForEval(context.Background())
+       if err != nil {
+               // Real OPA returns 500 on bundle compile errors at decision 
time; the
+               // filter treats non-200 as BadGateway, which is what we want.
+               w.WriteHeader(http.StatusInternalServerError)
+               _, _ = w.Write([]byte(err.Error()))
+               return
+       }
+
+       results, err := pq.Eval(r.Context(), rego.EvalInput(input))
+       if err != nil {
+               w.WriteHeader(http.StatusInternalServerError)
+               _, _ = w.Write([]byte(err.Error()))
+               return
+       }
+
+       resp := map[string]any{}
+       // No rules matched → omit "result" entirely. This is exactly what real
+       // OPA does, and it triggers the gateway's "missing 'result' field" 
branch
+       // — the same fail-closed behavior documented in test_opa.md §6.6.
+       if len(results) > 0 && len(results[0].Expressions) > 0 {
+               resp["result"] = results[0].Expressions[0].Value
+       }
+       w.Header().Set("Content-Type", "application/json")
+       _ = json.NewEncoder(w).Encode(resp)
+}
+
+func (m *regoMockOPA) recordedPUTs() []recordedRequest {
+       m.mu.Lock()
+       defer m.mu.Unlock()
+       out := make([]recordedRequest, len(m.putRequests))
+       copy(out, m.putRequests)
+       return out
+}
+
+// ---------------------------------------------------------------------------
+// Helpers for wiring admin router and gateway OPA filter against the mock.
+// ---------------------------------------------------------------------------
+
+// installAdminRouterWithRegoMock mounts the real admin router with
+// adminconfig.Bootstrap pointed at the given mock, mirroring installRouter()
+// in router_opa_test.go but accepting a regoMockOPA instead.
+func installAdminRouterWithRegoMock(t *testing.T, m *regoMockOPA, opaCfg 
adminconfig.OPAConfig) *gin.Engine {
+       t.Helper()
+       gin.SetMode(gin.TestMode)
+       t.Cleanup(func() { gin.SetMode(gin.DebugMode) })
+
+       prev := adminconfig.Bootstrap
+       cfg := opaCfg
+       if cfg.ServerURL == "" {
+               cfg.ServerURL = m.URL()
+       }
+       adminconfig.Bootstrap = &adminconfig.AdminBootstrap{OPA: cfg}
+       t.Cleanup(func() { adminconfig.Bootstrap = prev })
+
+       return Routers()
+}
+
+// adminPutPolicy uses the real /config/api/opa/policy PUT route, signed with
+// the same JWT key the middleware reads. The full request travels through
+// gin → JWT middleware → controller → logic → mock OPA, just like in prod.
+func adminPutPolicy(t *testing.T, r *gin.Engine, m *regoMockOPA, policyID, 
content string) {
+       t.Helper()
+       before := len(m.recordedPUTs())
+       fields := map[string]string{
+               "content":    content,
+               "server_url": m.URL(),
+       }
+       if policyID != "" {
+               fields["policy_id"] = policyID
+       }
+       ctype, body := putMultipart(t, fields)
+       req := httptest.NewRequest(http.MethodPut, "/config/api/opa/policy", 
body)
+       req.Header.Set("Content-Type", ctype)
+       req.Header.Set("token", signToken(t))
+
+       w := doReq(t, r, req)
+       if w.Code != http.StatusOK {
+               t.Fatalf("admin PUT failed: status=%d body=%s", w.Code, 
w.Body.String())
+       }
+       if !strings.Contains(w.Body.String(), "Update Success") {
+               t.Fatalf("admin PUT expected Update Success, got %s", 
w.Body.String())
+       }
+
+       puts := m.recordedPUTs()
+       if len(puts) != before+1 {
+               t.Fatalf("admin PUT did not reach mock OPA: before=%d after=%d 
calls=%+v", before, len(puts), puts)
+       }
+       if policyID != "" && puts[len(puts)-1].path != "/v1/policies/"+policyID 
{
+               t.Fatalf("admin PUT reached wrong OPA policy path: want %s got 
%s", "/v1/policies/"+policyID, puts[len(puts)-1].path)
+       }
+}
+
+// adminDeletePolicy hits DELETE /config/api/opa/policy.
+func adminDeletePolicy(t *testing.T, r *gin.Engine, serverURL, policyID 
string) {
+       t.Helper()
+       target := "/config/api/opa/policy"
+       query := url.Values{}
+       if serverURL != "" {
+               query.Set("server_url", serverURL)
+       }
+       if policyID != "" {
+               query.Set("policy_id", policyID)
+       }
+       if len(query) > 0 {
+               target = target + "?" + query.Encode()
+       }
+       req := httptest.NewRequest(http.MethodDelete, target, nil)
+       req.Header.Set("token", signToken(t))
+       w := doReq(t, r, req)
+       if w.Code != http.StatusOK {
+               t.Fatalf("admin DELETE failed: status=%d body=%s", w.Code, 
w.Body.String())
+       }
+}
+
+// buildGatewayFilter constructs and applies the real gateway OPA filter
+// (pkg/filter/opa) pointed at the same mock OPA the admin writes to.
+func buildGatewayFilter(t *testing.T, mockURL, decisionPath string, timeoutMs 
int) filter.HttpDecodeFilter {
+       t.Helper()
+       plugin := &opaFilter.Plugin{}
+       factory, err := plugin.CreateFilterFactory()
+       if err != nil {
+               t.Fatalf("create filter factory: %v", err)
+       }
+       cfg := factory.Config().(*opaFilter.Config)
+       cfg.ServerURL = mockURL
+       cfg.DecisionPath = decisionPath
+       cfg.TimeoutMs = timeoutMs
+       if err := factory.Apply(); err != nil {
+               t.Fatalf("apply gateway filter: %v", err)
+       }
+
+       chain := &e2eFilterChain{}
+       ctxStub := &contextHttp.HttpContext{
+               Request: httptest.NewRequest(http.MethodGet, "/", nil),
+               Writer:  httptest.NewRecorder(),
+               Ctx:     context.Background(),
+       }
+       if err := factory.PrepareFilterChain(ctxStub, chain); err != nil {
+               t.Fatalf("prepare gateway filter chain: %v", err)
+       }
+       if len(chain.filters) != 1 {
+               t.Fatalf("expected 1 decode filter, got %d", len(chain.filters))
+       }
+       return chain.filters[0]
+}
+
+// driveGatewayRequest runs one HTTP request through the gateway OPA filter
+// and returns the FilterStatus plus the captured HttpContext for status code
+// and response body inspection.
+func driveGatewayRequest(t *testing.T, f filter.HttpDecodeFilter, method, path 
string, headers map[string]string) (filter.FilterStatus, 
*contextHttp.HttpContext) {
+       t.Helper()
+       req := httptest.NewRequest(method, path, nil)
+       for k, v := range headers {
+               req.Header.Set(k, v)
+       }
+       ctx := &contextHttp.HttpContext{
+               Writer:  httptest.NewRecorder(),
+               Request: req,
+               Ctx:     context.Background(),
+       }
+       return f.Decode(ctx), ctx
+}
+
+type e2eFilterChain struct {
+       filters []filter.HttpDecodeFilter
+}
+
+func (c *e2eFilterChain) AppendDecodeFilters(f ...filter.HttpDecodeFilter) {
+       c.filters = append(c.filters, f...)
+}
+func (c *e2eFilterChain) AppendEncodeFilters(f ...filter.HttpEncodeFilter) {}
+func (c *e2eFilterChain) OnDecode(ctx *contextHttp.HttpContext)            {}
+func (c *e2eFilterChain) OnEncode(ctx *contextHttp.HttpContext)            {}
+
+// ---------------------------------------------------------------------------
+// Scenarios
+// ---------------------------------------------------------------------------
+
+const (
+       e2ePolicyID     = "pixiu-authz"
+       e2eDecisionPath = "/v1/data/pixiu/authz/allow"
+
+       // "allow GET only" — covers the headline allow case.
+       allowGETPolicy = `package pixiu.authz
+import future.keywords.if
+default allow := false
+allow if input.method == "GET"
+`
+
+       // "default allow := false" only — every request denied.
+       denyAllPolicy = `package pixiu.authz
+import future.keywords.if
+default allow := false
+`
+
+       // "allow if header X-Role == admin" — exercises header propagation.
+       headerRolePolicy = `package pixiu.authz
+import future.keywords.if
+default allow := false
+allow if input.headers["X-Role"][0] == "admin"
+`
+)
+
+//  1. Admin PUTs an "allow GET" policy. Gateway GET request is allowed
+//     end-to-end: filter returns Continue, no local reply written.
+func TestE2E_AllowedThroughFullChain(t *testing.T) {
+       mock := newRegoMockOPA(t)
+       r := installAdminRouterWithRegoMock(t, mock, adminconfig.OPAConfig{
+               PolicyID:       e2ePolicyID,
+               RequestTimeout: 2 * time.Second,
+       })
+
+       adminPutPolicy(t, r, mock, e2ePolicyID, allowGETPolicy)
+
+       // Verify the PUT actually reached OPA (full admin chain works).
+       puts := mock.recordedPUTs()
+       if len(puts) != 1 || puts[0].path != "/v1/policies/"+e2ePolicyID {
+               t.Fatalf("admin PUT didn't reach mock OPA correctly: %+v", puts)
+       }
+
+       gw := buildGatewayFilter(t, mock.URL(), e2eDecisionPath, 2000)
+       status, ctx := driveGatewayRequest(t, gw, http.MethodGet, "/anything", 
nil)
+
+       if status != filter.Continue {
+               t.Fatalf("GET should be allowed, got status=%v code=%d body=%s",
+                       status, ctx.GetStatusCode(), 
string(ctx.GetLocalReplyBody()))
+       }
+       if ctx.LocalReply() {
+               t.Errorf("Continue must not write a local reply")
+       }
+}
+
+//  2. Same policy, gateway POST is denied — proves the deny path returns
+//     filter.Stop with 403 and that the gateway short-circuits before reaching
+//     any upstream.
+func TestE2E_DeniedThroughFullChain(t *testing.T) {
+       mock := newRegoMockOPA(t)
+       r := installAdminRouterWithRegoMock(t, mock, adminconfig.OPAConfig{
+               PolicyID:       e2ePolicyID,
+               RequestTimeout: 2 * time.Second,
+       })
+       adminPutPolicy(t, r, mock, e2ePolicyID, allowGETPolicy)
+
+       gw := buildGatewayFilter(t, mock.URL(), e2eDecisionPath, 2000)
+       status, ctx := driveGatewayRequest(t, gw, http.MethodPost, "/anything", 
nil)
+
+       if status != filter.Stop {
+               t.Fatalf("POST should be denied, got %v", status)
+       }
+       if ctx.GetStatusCode() != http.StatusForbidden {
+               t.Errorf("deny status: want 403, got %d body=%s",
+                       ctx.GetStatusCode(), string(ctx.GetLocalReplyBody()))
+       }
+}
+
+//  3. "default allow := false" with no allow rule — every method/path denied.
+//     Subtests share one mock+filter to confirm the deny is policy-driven, not
+//     request-shape-dependent.
+func TestE2E_DefaultDenyForAllRequests(t *testing.T) {
+       mock := newRegoMockOPA(t)
+       r := installAdminRouterWithRegoMock(t, mock, adminconfig.OPAConfig{
+               PolicyID:       e2ePolicyID,
+               RequestTimeout: 2 * time.Second,
+       })
+       adminPutPolicy(t, r, mock, e2ePolicyID, denyAllPolicy)
+       gw := buildGatewayFilter(t, mock.URL(), e2eDecisionPath, 2000)
+
+       cases := []struct {
+               method, path string
+       }{
+               {http.MethodGet, "/"},
+               {http.MethodGet, "/users/1"},
+               {http.MethodPost, "/api/x"},
+               {http.MethodPut, "/anything"},
+               {http.MethodDelete, "/secret"},
+       }
+       for _, c := range cases {
+               c := c
+               t.Run(c.method+" "+c.path, func(t *testing.T) {
+                       status, ctx := driveGatewayRequest(t, gw, c.method, 
c.path, nil)
+                       if status != filter.Stop || ctx.GetStatusCode() != 
http.StatusForbidden {
+                               t.Errorf("expected deny+403, got status=%v 
code=%d",
+                                       status, ctx.GetStatusCode())
+                       }
+               })
+       }
+}
+
+//  4. Policy hot-reload through the admin REST API — gateway sees the new
+//     decision on the *next* request, with no restart. This is the key value
+//     proposition of OPA server mode vs. embedded mode.
+func TestE2E_PolicyHotReload(t *testing.T) {
+       mock := newRegoMockOPA(t)
+       r := installAdminRouterWithRegoMock(t, mock, adminconfig.OPAConfig{
+               PolicyID:       e2ePolicyID,
+               RequestTimeout: 2 * time.Second,
+       })
+       gw := buildGatewayFilter(t, mock.URL(), e2eDecisionPath, 2000)
+
+       // v1: only GET allowed.
+       adminPutPolicy(t, r, mock, e2ePolicyID, allowGETPolicy)
+       if status, _ := driveGatewayRequest(t, gw, http.MethodGet, "/", nil); 
status != filter.Continue {
+               t.Fatalf("v1: GET should be allowed, got %v", status)
+       }
+       if status, _ := driveGatewayRequest(t, gw, http.MethodPost, "/", nil); 
status != filter.Stop {
+               t.Fatalf("v1: POST should be denied, got %v", status)
+       }
+
+       // v2: flip — only POST allowed.
+       adminPutPolicy(t, r, mock, e2ePolicyID, `package pixiu.authz
+import future.keywords.if
+default allow := false
+allow if input.method == "POST"
+`)
+
+       if status, _ := driveGatewayRequest(t, gw, http.MethodGet, "/", nil); 
status != filter.Stop {
+               t.Errorf("v2: GET should now be denied, got %v", status)
+       }
+       if status, _ := driveGatewayRequest(t, gw, http.MethodPost, "/", nil); 
status != filter.Continue {
+               t.Errorf("v2: POST should now be allowed, got %v", status)
+       }
+}
+
+//  5. After DELETE, no rules are loaded → mock OPA returns a body with no
+//     "result" field, the gateway filter must fail closed with BadGateway.
+//     This locks in the §6.6 invariant from test_opa.md.
+func TestE2E_DeleteCausesMissingResultFailClosed(t *testing.T) {
+       mock := newRegoMockOPA(t)
+       r := installAdminRouterWithRegoMock(t, mock, adminconfig.OPAConfig{
+               PolicyID:       e2ePolicyID,
+               RequestTimeout: 2 * time.Second,
+       })
+       adminPutPolicy(t, r, mock, e2ePolicyID, allowGETPolicy)
+       gw := buildGatewayFilter(t, mock.URL(), e2eDecisionPath, 2000)
+
+       // Sanity: allowed before delete.
+       if status, _ := driveGatewayRequest(t, gw, http.MethodGet, "/", nil); 
status != filter.Continue {
+               t.Fatalf("pre-delete: GET should be allowed, got %v", status)
+       }
+
+       adminDeletePolicy(t, r, mock.URL(), e2ePolicyID)
+
+       status, ctx := driveGatewayRequest(t, gw, http.MethodGet, "/", nil)
+       if status != filter.Stop {
+               t.Fatalf("post-delete: expected Stop, got %v", status)
+       }
+       if ctx.GetStatusCode() != http.StatusBadGateway {
+               t.Errorf("post-delete: expected 502 (missing 'result'), got %d 
body=%s",
+                       ctx.GetStatusCode(), string(ctx.GetLocalReplyBody()))
+       }
+}
+
+//  6. Header-based policy: gateway forwards input.headers to OPA, and headers
+//     are canonicalised by net/http to the Title-Case form
+//     (X-Role, not x-role). This proves the gateway request shape contract.
+func TestE2E_HeaderBasedAllowDeny(t *testing.T) {
+       mock := newRegoMockOPA(t)
+       r := installAdminRouterWithRegoMock(t, mock, adminconfig.OPAConfig{
+               PolicyID:       e2ePolicyID,
+               RequestTimeout: 2 * time.Second,
+       })
+       adminPutPolicy(t, r, mock, e2ePolicyID, headerRolePolicy)
+       gw := buildGatewayFilter(t, mock.URL(), e2eDecisionPath, 2000)
+
+       t.Run("admin header allowed", func(t *testing.T) {
+               status, ctx := driveGatewayRequest(t, gw, http.MethodGet, 
"/admin",
+                       map[string]string{"X-Role": "admin"})
+               if status != filter.Continue {
+                       t.Errorf("expected Continue, got %v code=%d body=%s",
+                               status, ctx.GetStatusCode(), 
string(ctx.GetLocalReplyBody()))
+               }
+       })
+
+       t.Run("user header denied", func(t *testing.T) {
+               status, ctx := driveGatewayRequest(t, gw, http.MethodGet, 
"/admin",
+                       map[string]string{"X-Role": "user"})
+               if status != filter.Stop || ctx.GetStatusCode() != 
http.StatusForbidden {
+                       t.Errorf("expected Stop+403, got status=%v code=%d",
+                               status, ctx.GetStatusCode())
+               }
+       })
+
+       t.Run("missing header denied", func(t *testing.T) {
+               status, ctx := driveGatewayRequest(t, gw, http.MethodGet, 
"/admin", nil)
+               if status != filter.Stop || ctx.GetStatusCode() != 
http.StatusForbidden {
+                       t.Errorf("expected Stop+403, got status=%v code=%d",
+                               status, ctx.GetStatusCode())
+               }
+       })
+}
+
+//  7. Slow mock OPA + short gateway timeout → gateway returns 504 
GatewayTimeout
+//     on the next decision. Verifies the gateway-side timeout config really
+//     fires under network slowness, matching the §6.4 manual finding.
+func TestE2E_GatewayTimeoutFailClosed(t *testing.T) {
+       mock := newRegoMockOPA(t)
+       r := installAdminRouterWithRegoMock(t, mock, adminconfig.OPAConfig{
+               PolicyID:       e2ePolicyID,
+               RequestTimeout: 2 * time.Second,
+       })
+       adminPutPolicy(t, r, mock, e2ePolicyID, allowGETPolicy)
+
+       // 200ms delay on decision; 50ms filter timeout → must time out.
+       mock.decisionDelay = 200 * time.Millisecond
+       gw := buildGatewayFilter(t, mock.URL(), e2eDecisionPath, 50)
+
+       start := time.Now()
+       status, ctx := driveGatewayRequest(t, gw, http.MethodGet, "/", nil)
+       elapsed := time.Since(start)
+
+       if status != filter.Stop {
+               t.Fatalf("expected Stop on timeout, got %v", status)
+       }
+       if ctx.GetStatusCode() != http.StatusGatewayTimeout {
+               t.Errorf("expected 504, got %d body=%s",
+                       ctx.GetStatusCode(), string(ctx.GetLocalReplyBody()))
+       }
+       if elapsed > 180*time.Millisecond {
+               t.Errorf("timeout fired too late (%v); 50ms config likely 
ignored", elapsed)
+       }
+}
+
+//  8. PUT a policy with a different policy_id via the admin REST form 
override,
+//     then point the gateway's decision path at the rule of *that* policy. 
This
+//     proves the override flag in PR1's controller propagates all the way
+//     through to a working gateway decision.
+func TestE2E_PolicyIDOverrideRoutesThroughGateway(t *testing.T) {
+       mock := newRegoMockOPA(t)
+       r := installAdminRouterWithRegoMock(t, mock, adminconfig.OPAConfig{
+               PolicyID:       e2ePolicyID, // Bootstrap default; we'll 
override below.
+               RequestTimeout: 2 * time.Second,
+       })
+
+       const overrideID = "tenant-a-policy"
+       const overridePackage = `package tenant.a
+import future.keywords.if
+default allow := false
+allow if input.method == "GET"
+`
+       adminPutPolicy(t, r, mock, overrideID, overridePackage)
+
+       puts := mock.recordedPUTs()
+       if len(puts) != 1 || puts[0].path != "/v1/policies/"+overrideID {
+               t.Fatalf("admin override didn't land at overridden policy_id: 
%+v", puts)
+       }
+
+       // Gateway points at the override package's rule.
+       gw := buildGatewayFilter(t, mock.URL(), "/v1/data/tenant/a/allow", 2000)
+       if status, _ := driveGatewayRequest(t, gw, http.MethodGet, "/", nil); 
status != filter.Continue {
+               t.Errorf("override GET should be allowed")
+       }
+       if status, _ := driveGatewayRequest(t, gw, http.MethodPost, "/", nil); 
status != filter.Stop {
+               t.Errorf("override POST should be denied")
+       }
+}
diff --git a/admin/initialize/opa_http_test.go 
b/admin/initialize/opa_http_test.go
new file mode 100644
index 000000000..64c22e305
--- /dev/null
+++ b/admin/initialize/opa_http_test.go
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package initialize
+
+import (
+       "net"
+       "net/http"
+       "net/http/httptest"
+       "testing"
+)
+
+func useDirectHTTPTransport(t *testing.T) {
+       t.Helper()
+       prev := http.DefaultTransport
+       transport := directHTTPTransport()
+       http.DefaultTransport = transport
+       t.Cleanup(func() {
+               http.DefaultTransport = prev
+               transport.CloseIdleConnections()
+       })
+}
+
+func directHTTPTransport() *http.Transport {
+       if base, ok := http.DefaultTransport.(*http.Transport); ok && base != 
nil {
+               transport := base.Clone()
+               transport.Proxy = nil
+               return transport
+       }
+       return &http.Transport{Proxy: nil}
+}
+
+func startLoopbackHTTPServer(t *testing.T, handler http.Handler) 
*httptest.Server {
+       t.Helper()
+       listener, err := net.Listen("tcp4", "127.0.0.1:0")
+       if err != nil {
+               t.Fatalf("listen on 127.0.0.1:0: %v", err)
+       }
+       srv := httptest.NewUnstartedServer(handler)
+       srv.Listener = listener
+       srv.Start()
+       t.Cleanup(srv.Close)
+       return srv
+}
diff --git a/admin/initialize/router_opa_test.go 
b/admin/initialize/router_opa_test.go
new file mode 100644
index 000000000..5ccfab883
--- /dev/null
+++ b/admin/initialize/router_opa_test.go
@@ -0,0 +1,402 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package initialize
+
+import (
+       "bytes"
+       "encoding/json"
+       "io"
+       "mime/multipart"
+       "net/http"
+       "net/http/httptest"
+       "strings"
+       "sync"
+       "testing"
+       "time"
+)
+
+import (
+       "github.com/gin-gonic/gin"
+
+       "github.com/golang-jwt/jwt/v4"
+)
+
+import (
+       adminconfig "github.com/apache/dubbo-go-pixiu/admin/config"
+       "github.com/apache/dubbo-go-pixiu/admin/controller/auth"
+)
+
+// This file is the only place in the test suite that exercises the full
+// admin startup pipeline that handles OPA requests:
+//
+//     YAML → adminconfig.Bootstrap.OPA
+//            → initialize.Routers() registers /config/api/opa/policy
+//            → auth.JWTAuth() middleware validates "token" header
+//            → opa.PutOPAPolicy/GetOPAPolicy/DeleteOPAPolicy handlers
+//            → logic.BizPut/Get/Delete... → real HTTP call to OPA server
+//
+// We stand up an httptest server as the OPA backend, point
+// adminconfig.Bootstrap.OPA.ServerURL at it, and sign JWTs with the
+// SAME hardcoded SignKey ("dubbo-go-pixiu") the middleware reads from
+// admin/controller/auth/auth.go:83.
+// ----- helpers 
---------------------------------------------------------------
+type recordedRequest struct {
+       method string
+       path   string
+       ctype  string
+       auth   string
+       body   string
+}
+
+type mockOPA struct {
+       srv  *httptest.Server
+       mu   sync.Mutex
+       recs []recordedRequest
+       // policies stores PUT'd bodies keyed by policy ID so GETs return them.
+       policies map[string]string
+       // nextStatus, if non-zero, overrides the success status on the next 
request.
+       nextStatus int
+       nextBody   string
+}
+
+func newMockOPA(t *testing.T) *mockOPA {
+       t.Helper()
+       useDirectHTTPTransport(t)
+       m := &mockOPA{policies: map[string]string{}}
+       m.srv = startLoopbackHTTPServer(t, http.HandlerFunc(m.handle))
+       return m
+}
+
+func (m *mockOPA) handle(w http.ResponseWriter, r *http.Request) {
+       b, _ := io.ReadAll(r.Body)
+       m.mu.Lock()
+       m.recs = append(m.recs, recordedRequest{
+               method: r.Method,
+               path:   r.URL.Path,
+               ctype:  r.Header.Get("Content-Type"),
+               auth:   r.Header.Get("Authorization"),
+               body:   string(b),
+       })
+       override, overrideBody := m.nextStatus, m.nextBody
+       m.nextStatus, m.nextBody = 0, ""
+       m.mu.Unlock()
+
+       if override != 0 {
+               w.WriteHeader(override)
+               if overrideBody != "" {
+                       _, _ = w.Write([]byte(overrideBody))
+               }
+               return
+       }
+
+       if strings.HasPrefix(r.URL.Path, "/v1/policies/") {
+               id := strings.TrimPrefix(r.URL.Path, "/v1/policies/")
+               switch r.Method {
+               case http.MethodPut:
+                       m.mu.Lock()
+                       m.policies[id] = string(b)
+                       m.mu.Unlock()
+                       w.WriteHeader(http.StatusOK)
+                       _, _ = w.Write([]byte("{}"))
+               case http.MethodGet:
+                       m.mu.Lock()
+                       raw, ok := m.policies[id]
+                       m.mu.Unlock()
+                       if !ok {
+                               w.WriteHeader(http.StatusNotFound)
+                               return
+                       }
+                       w.Header().Set("Content-Type", "application/json")
+                       w.WriteHeader(http.StatusOK)
+                       _ = json.NewEncoder(w).Encode(map[string]any{
+                               "result": map[string]any{"id": id, "raw": raw},
+                       })
+               case http.MethodDelete:
+                       m.mu.Lock()
+                       delete(m.policies, id)
+                       m.mu.Unlock()
+                       w.WriteHeader(http.StatusOK)
+               }
+               return
+       }
+       w.WriteHeader(http.StatusNotFound)
+}
+
+func (m *mockOPA) records() []recordedRequest {
+       m.mu.Lock()
+       defer m.mu.Unlock()
+       out := make([]recordedRequest, len(m.recs))
+       copy(out, m.recs)
+       return out
+}
+
+// signToken signs a JWT with the same hardcoded key the middleware uses,
+// so the resulting token passes JWTAuth without any DB lookup.
+func signToken(t *testing.T) string {
+       t.Helper()
+       claims := auth.CustomClaims{
+               Username: "e2e",
+               StandardClaims: jwt.StandardClaims{
+                       ExpiresAt: time.Now().Add(time.Hour).Unix(),
+                       Issuer:    "router-test",
+               },
+       }
+       tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+       s, err := tok.SignedString([]byte(auth.GetSignKey()))
+       if err != nil {
+               t.Fatalf("sign: %v", err)
+       }
+       return s
+}
+
+// installRouter builds the real Routers() and points OPAConfig at the mock.
+// Restores both gin mode and the global Bootstrap on cleanup.
+func installRouter(t *testing.T, m *mockOPA, opaCfg adminconfig.OPAConfig) 
*gin.Engine {
+       t.Helper()
+       gin.SetMode(gin.TestMode)
+       t.Cleanup(func() { gin.SetMode(gin.DebugMode) })
+
+       prev := adminconfig.Bootstrap
+       cfg := opaCfg
+       if cfg.ServerURL == "" {
+               cfg.ServerURL = m.srv.URL
+       }
+       adminconfig.Bootstrap = &adminconfig.AdminBootstrap{OPA: cfg}
+       t.Cleanup(func() { adminconfig.Bootstrap = prev })
+
+       return Routers()
+}
+
+func putMultipart(t *testing.T, fields map[string]string) (string, 
*bytes.Buffer) {
+       t.Helper()
+       body := &bytes.Buffer{}
+       mw := multipart.NewWriter(body)
+       for k, v := range fields {
+               _ = mw.WriteField(k, v)
+       }
+       _ = mw.Close()
+       return mw.FormDataContentType(), body
+}
+
+func doReq(t *testing.T, r *gin.Engine, req *http.Request) 
*httptest.ResponseRecorder {
+       t.Helper()
+       w := httptest.NewRecorder()
+       r.ServeHTTP(w, req)
+       return w
+}
+
+// ----- tests ----------------------------------------------------------------
+
+// 1. JWT middleware actually gates /config/api/opa/policy.
+func TestOPARoutes_NoTokenRejected(t *testing.T) {
+       m := newMockOPA(t)
+       r := installRouter(t, m, adminconfig.OPAConfig{PolicyID: "p", 
RequestTimeout: time.Second})
+
+       w := doReq(t, r, httptest.NewRequest(http.MethodGet, 
"/config/api/opa/policy", nil))
+
+       if w.Code != http.StatusOK {
+               t.Fatalf("status: %d", w.Code)
+       }
+       if !strings.Contains(w.Body.String(), "does not carry token") {
+               t.Fatalf("body should mention token: %s", w.Body.String())
+       }
+       if len(m.records()) != 0 {
+               t.Fatalf("mock OPA should not be called when JWT fails, got %d 
calls", len(m.records()))
+       }
+}
+
+// 2. With valid JWT, PUT with no form policy_id uses Bootstrap default → OPA
+// gets /v1/policies/<bootstrap-policy-id> with text/plain body.
+func TestOPARoutes_PutUsesBootstrapDefaults(t *testing.T) {
+       m := newMockOPA(t)
+       r := installRouter(t, m, adminconfig.OPAConfig{PolicyID: "from-config", 
RequestTimeout: 2 * time.Second})
+
+       ctype, body := putMultipart(t, map[string]string{
+               "content": "package pixiu\ndefault allow = false",
+       })
+       req := httptest.NewRequest(http.MethodPut, "/config/api/opa/policy", 
body)
+       req.Header.Set("Content-Type", ctype)
+       req.Header.Set("token", signToken(t))
+
+       w := doReq(t, r, req)
+       if w.Code != http.StatusOK {
+               t.Fatalf("status %d body=%s", w.Code, w.Body.String())
+       }
+       if !strings.Contains(w.Body.String(), "Update Success") {
+               t.Fatalf("expected Update Success, got %s", w.Body.String())
+       }
+
+       recs := m.records()
+       if len(recs) != 1 {
+               t.Fatalf("expected 1 call to OPA, got %d", len(recs))
+       }
+       if recs[0].method != http.MethodPut || recs[0].path != 
"/v1/policies/from-config" {
+               t.Errorf("wrong upstream request: %+v", recs[0])
+       }
+       if recs[0].ctype != "text/plain" {
+               t.Errorf("ctype: want text/plain, got %s", recs[0].ctype)
+       }
+       if recs[0].body != "package pixiu\ndefault allow = false" {
+               t.Errorf("body: %q", recs[0].body)
+       }
+}
+
+// 3. Form policy_id overrides Bootstrap; bearer_token is forwarded as
+// Authorization: Bearer ...
+func TestOPARoutes_PutFormOverridesAndBearer(t *testing.T) {
+       m := newMockOPA(t)
+       r := installRouter(t, m, adminconfig.OPAConfig{PolicyID: "from-config", 
RequestTimeout: time.Second})
+
+       ctype, body := putMultipart(t, map[string]string{
+               "policy_id":    "override-id",
+               "bearer_token": "secret-123",
+               "content":      "package over\nallow = true",
+       })
+       req := httptest.NewRequest(http.MethodPut, "/config/api/opa/policy", 
body)
+       req.Header.Set("Content-Type", ctype)
+       req.Header.Set("token", signToken(t))
+
+       w := doReq(t, r, req)
+       if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), 
"Update Success") {
+               t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
+       }
+
+       recs := m.records()
+       if len(recs) != 1 {
+               t.Fatalf("expected 1 call to OPA, got %d", len(recs))
+       }
+       if recs[0].path != "/v1/policies/override-id" {
+               t.Errorf("override failed: path=%s", recs[0].path)
+       }
+       if recs[0].auth != "Bearer secret-123" {
+               t.Errorf("bearer not forwarded: auth=%q", recs[0].auth)
+       }
+}
+
+// 4. GET via the full chain decodes raw from OPA.
+func TestOPARoutes_GetReadsBack(t *testing.T) {
+       m := newMockOPA(t)
+       m.policies["from-config"] = "package pixiu\nallow = true"
+       r := installRouter(t, m, adminconfig.OPAConfig{PolicyID: "from-config", 
RequestTimeout: time.Second})
+
+       req := httptest.NewRequest(http.MethodGet, "/config/api/opa/policy", 
nil)
+       req.Header.Set("token", signToken(t))
+
+       w := doReq(t, r, req)
+       if w.Code != http.StatusOK {
+               t.Fatalf("status %d", w.Code)
+       }
+       var resp map[string]any
+       if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+               t.Fatalf("decode: %v", err)
+       }
+       if resp["data"] != "package pixiu\nallow = true" {
+               t.Errorf("data: %v", resp["data"])
+       }
+}
+
+// 5. DELETE via the full chain hits the right URL.
+func TestOPARoutes_DeleteRoute(t *testing.T) {
+       m := newMockOPA(t)
+       r := installRouter(t, m, adminconfig.OPAConfig{PolicyID: "from-config", 
RequestTimeout: time.Second})
+
+       req := httptest.NewRequest(http.MethodDelete, "/config/api/opa/policy", 
nil)
+       req.Header.Set("token", signToken(t))
+
+       if w := doReq(t, r, req); w.Code != http.StatusOK {
+               t.Fatalf("status %d body=%s", w.Code, w.Body.String())
+       }
+       recs := m.records()
+       if len(recs) != 1 {
+               t.Fatalf("expected 1 call to OPA, got %d", len(recs))
+       }
+       if recs[0].method != http.MethodDelete || recs[0].path != 
"/v1/policies/from-config" {
+               t.Errorf("wrong call: %+v", recs[0])
+       }
+}
+
+// 6. OPAConfig.RequestTimeout actually fires through the full chain.
+// Slow mock + 200ms config → ~200ms-ish elapsed and a context-deadline error
+// surfaces in the response body.
+func TestOPARoutes_RequestTimeoutThroughFullChain(t *testing.T) {
+       slow := startLoopbackHTTPServer(t, http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               time.Sleep(2 * time.Second)
+               w.WriteHeader(http.StatusOK)
+       }))
+
+       // Use newMockOPA only to satisfy installRouter's signature; override 
URL.
+       m := newMockOPA(t)
+       r := installRouter(t, m, adminconfig.OPAConfig{
+               ServerURL:      slow.URL,
+               PolicyID:       "p",
+               RequestTimeout: 200 * time.Millisecond,
+       })
+
+       req := httptest.NewRequest(http.MethodGet, "/config/api/opa/policy", 
nil)
+       req.Header.Set("token", signToken(t))
+
+       start := time.Now()
+       w := doReq(t, r, req)
+       elapsed := time.Since(start)
+
+       if !strings.Contains(w.Body.String(), "context deadline exceeded") {
+               t.Errorf("expected timeout error in body, got: %s", 
w.Body.String())
+       }
+       if elapsed > 1500*time.Millisecond {
+               t.Errorf("timeout fired too late (%v); config likely ignored", 
elapsed)
+       }
+       if elapsed < 150*time.Millisecond {
+               t.Errorf("timeout fired too early (%v)", elapsed)
+       }
+}
+
+// 7. End-to-end loop: PUT a policy through the route, then GET it back —
+// proves the admin REST → OPA → admin REST round-trip works with real
+// JWT + Gin + httpClient + httptest OPA.
+func TestOPARoutes_RoundTrip(t *testing.T) {
+       m := newMockOPA(t)
+       r := installRouter(t, m, adminconfig.OPAConfig{PolicyID: "rt-policy", 
RequestTimeout: time.Second})
+       token := signToken(t)
+
+       rego := "package rt\nimport rego.v1\ndefault allow := false\nallow if 
input.method == \"GET\""
+
+       // PUT
+       ctype, body := putMultipart(t, map[string]string{"content": rego})
+       putReq := httptest.NewRequest(http.MethodPut, "/config/api/opa/policy", 
body)
+       putReq.Header.Set("Content-Type", ctype)
+       putReq.Header.Set("token", token)
+       if w := doReq(t, r, putReq); w.Code != http.StatusOK ||
+               !strings.Contains(w.Body.String(), "Update Success") {
+               t.Fatalf("PUT failed: status=%d body=%s", w.Code, 
w.Body.String())
+       }
+
+       // GET
+       getReq := httptest.NewRequest(http.MethodGet, "/config/api/opa/policy", 
nil)
+       getReq.Header.Set("token", token)
+       w := doReq(t, r, getReq)
+       if w.Code != http.StatusOK {
+               t.Fatalf("GET status %d", w.Code)
+       }
+       var resp map[string]any
+       if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+               t.Fatalf("decode GET response: %v", err)
+       }
+       if resp["data"] != rego {
+               t.Errorf("round-tripped policy mismatch:\nput:  %q\nback: %v", 
rego, resp["data"])
+       }
+}
diff --git a/admin/initialize/run.sh b/admin/initialize/run.sh
new file mode 100755
index 000000000..d6117d53f
--- /dev/null
+++ b/admin/initialize/run.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+#
+#  Licensed to the Apache Software Foundation (ASF) under one or more
+#  contributor license agreements.  See the NOTICE file distributed with
+#  this work for additional information regarding copyright ownership.
+#  The ASF licenses this file to You under the Apache License, Version 2.0
+#  (the "License"); you may not use this file except in compliance with
+#  the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+#
+
+# PR2 OPA end-to-end test runner.
+#
+# This script is a thin convenience wrapper around `go test`. It runs the
+# full-link suite that lives in admin/initialize/e2e_opa_test.go — that file
+# stands up a smart in-process OPA mock (real github.com/open-policy-agent/opa
+# rego library), drives the admin REST API to publish policies, then exercises
+# the gateway OPA filter against the same mock to verify allow/deny decisions.
+#
+# Usage:
+#     ./admin/initialize/run.sh            # run all PR2 E2E cases
+#     ./admin/initialize/run.sh -run Allow # filter cases by name
+#     VERBOSE=1 ./admin/initialize/run.sh  # add -v
+#
+# Requirements:
+#     - Go toolchain (matches go.mod's version directive)
+#     - No docker, etcd, mysql, or real OPA binary required.
+
+set -euo pipefail
+
+cd "$(dirname "$0")/../.."
+
+ARGS=("-count=1" "-run" "TestE2E_")
+if [[ "${VERBOSE:-0}" == "1" ]]; then
+    ARGS+=("-v")
+fi
+
+# Forward any extra args (e.g. -run regex override) after our defaults.
+go test "${ARGS[@]}" "$@" ./admin/initialize/
diff --git a/admin/logic/opa_test.go b/admin/logic/opa_test.go
new file mode 100644
index 000000000..a5181c058
--- /dev/null
+++ b/admin/logic/opa_test.go
@@ -0,0 +1,290 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package logic
+
+import (
+       "io"
+       "net"
+       "net/http"
+       "net/http/httptest"
+       "strings"
+       "sync"
+       "testing"
+       "time"
+)
+
+import (
+       adminconfig "github.com/apache/dubbo-go-pixiu/admin/config"
+)
+
+// setBootstrap installs a temporary Bootstrap for the test and restores it 
after.
+func setBootstrap(t *testing.T, b *adminconfig.AdminBootstrap) {
+       t.Helper()
+       prev := adminconfig.Bootstrap
+       adminconfig.Bootstrap = b
+       t.Cleanup(func() { adminconfig.Bootstrap = prev })
+}
+
+func setOPAHTTPClient(t *testing.T, client *http.Client) {
+       t.Helper()
+       prev := opaHTTPClient
+       opaHTTPClient = client
+       t.Cleanup(func() {
+               opaHTTPClient = prev
+               client.CloseIdleConnections()
+       })
+}
+
+func directOPAHTTPClient() *http.Client {
+       transport := http.DefaultTransport.(*http.Transport).Clone()
+       transport.Proxy = nil
+       return &http.Client{
+               Transport: transport,
+               Timeout:   opaHTTPClientFallbackTimeout,
+       }
+}
+
+func startLoopbackHTTPServer(t *testing.T, handler http.Handler) 
*httptest.Server {
+       t.Helper()
+       listener, err := net.Listen("tcp4", "127.0.0.1:0")
+       if err != nil {
+               t.Fatalf("listen on 127.0.0.1:0: %v", err)
+       }
+       srv := httptest.NewUnstartedServer(handler)
+       srv.Listener = listener
+       srv.Start()
+       t.Cleanup(srv.Close)
+       return srv
+}
+
+func TestGetOPATimeout_FallsBackToDefaultWhenUnset(t *testing.T) {
+       setBootstrap(t, nil)
+       if got := getOPATimeout(); got != adminconfig.DefaultOPAPolicyTimeout {
+               t.Fatalf("nil Bootstrap: want default %v, got %v", 
adminconfig.DefaultOPAPolicyTimeout, got)
+       }
+
+       setBootstrap(t, &adminconfig.AdminBootstrap{}) // RequestTimeout == 0
+       if got := getOPATimeout(); got != adminconfig.DefaultOPAPolicyTimeout {
+               t.Fatalf("zero RequestTimeout: want default %v, got %v", 
adminconfig.DefaultOPAPolicyTimeout, got)
+       }
+}
+
+func TestGetOPATimeout_UsesConfigValue(t *testing.T) {
+       setBootstrap(t, &adminconfig.AdminBootstrap{
+               OPA: adminconfig.OPAConfig{RequestTimeout: 3 * time.Second},
+       })
+       if got := getOPATimeout(); got != 3*time.Second {
+               t.Fatalf("want 3s from config, got %v", got)
+       }
+}
+
+func TestBuildOPAPolicyURL(t *testing.T) {
+       cases := []struct {
+               name, server, policy, want string
+               wantErr                    bool
+       }{
+               {"basic", "http://opa:8181";, "pid", 
"http://opa:8181/v1/policies/pid";, false},
+               {"trims trailing slash", "http://opa:8181/";, "pid", 
"http://opa:8181/v1/policies/pid";, false},
+               {"trims whitespace", "  http://opa:8181  ", "  pid  ", 
"http://opa:8181/v1/policies/pid";, false},
+               {"empty server", "", "pid", "", true},
+               {"empty policy", "http://opa:8181";, "", "", true},
+       }
+       for _, c := range cases {
+               t.Run(c.name, func(t *testing.T) {
+                       got, err := buildOPAPolicyURL(c.server, c.policy)
+                       if c.wantErr {
+                               if err == nil {
+                                       t.Fatalf("want error, got url=%q", got)
+                               }
+                               return
+                       }
+                       if err != nil {
+                               t.Fatalf("unexpected error: %v", err)
+                       }
+                       if got != c.want {
+                               t.Fatalf("want %q, got %q", c.want, got)
+                       }
+               })
+       }
+}
+
+type recordedRequest struct {
+       method        string
+       path          string
+       contentType   string
+       authorization string
+       body          string
+}
+
+// startMockOPA spins up an httptest server that records each request and
+// responds with `status` and `body` (body may be empty).
+func startMockOPA(t *testing.T, status int, body string) (*httptest.Server, 
*[]recordedRequest, *sync.Mutex) {
+       t.Helper()
+       setOPAHTTPClient(t, directOPAHTTPClient())
+       var (
+               mu   sync.Mutex
+               recs []recordedRequest
+       )
+       srv := startLoopbackHTTPServer(t, http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               b, _ := io.ReadAll(r.Body)
+               mu.Lock()
+               recs = append(recs, recordedRequest{
+                       method:        r.Method,
+                       path:          r.URL.Path,
+                       contentType:   r.Header.Get("Content-Type"),
+                       authorization: r.Header.Get("Authorization"),
+                       body:          string(b),
+               })
+               mu.Unlock()
+               w.WriteHeader(status)
+               if body != "" {
+                       _, _ = w.Write([]byte(body))
+               }
+       }))
+       return srv, &recs, &mu
+}
+
+func TestBizPutOPAPolicy_SendsCorrectRequest(t *testing.T) {
+       srv, recs, mu := startMockOPA(t, http.StatusNoContent, "")
+
+       err := BizPutOPAPolicy(srv.URL, "my-policy", "tok123", "package 
pixiu\r\ndefault allow = false")
+       if err != nil {
+               t.Fatalf("BizPutOPAPolicy: %v", err)
+       }
+
+       mu.Lock()
+       defer mu.Unlock()
+       if len(*recs) != 1 {
+               t.Fatalf("want 1 request, got %d", len(*recs))
+       }
+       r := (*recs)[0]
+       if r.method != http.MethodPut {
+               t.Errorf("method: want PUT, got %s", r.method)
+       }
+       if r.path != "/v1/policies/my-policy" {
+               t.Errorf("path: want /v1/policies/my-policy, got %s", r.path)
+       }
+       if r.contentType != "text/plain" {
+               t.Errorf("content-type: want text/plain, got %s", r.contentType)
+       }
+       if r.authorization != "Bearer tok123" {
+               t.Errorf("authorization: want 'Bearer tok123', got %q", 
r.authorization)
+       }
+       // \r\n must be normalized to \n
+       if r.body != "package pixiu\ndefault allow = false" {
+               t.Errorf("body not normalized: %q", r.body)
+       }
+}
+
+func TestBizPutOPAPolicy_NoBearerTokenOmitsHeader(t *testing.T) {
+       srv, recs, mu := startMockOPA(t, http.StatusNoContent, "")
+
+       if err := BizPutOPAPolicy(srv.URL, "p", "", "package x"); err != nil {
+               t.Fatalf("BizPutOPAPolicy: %v", err)
+       }
+
+       mu.Lock()
+       defer mu.Unlock()
+       if (*recs)[0].authorization != "" {
+               t.Errorf("expected no Authorization header, got %q", 
(*recs)[0].authorization)
+       }
+}
+
+func TestBizGetOPAPolicy_DecodesRaw(t *testing.T) {
+       body := `{"result":{"id":"p","raw":"package pixiu\ndefault allow = 
true"}}`
+       srv, recs, mu := startMockOPA(t, http.StatusOK, body)
+
+       got, err := BizGetOPAPolicy(srv.URL, "p", "")
+       if err != nil {
+               t.Fatalf("BizGetOPAPolicy: %v", err)
+       }
+       if got != "package pixiu\ndefault allow = true" {
+               t.Errorf("decoded raw mismatch: %q", got)
+       }
+
+       mu.Lock()
+       defer mu.Unlock()
+       if (*recs)[0].method != http.MethodGet || (*recs)[0].path != 
"/v1/policies/p" {
+               t.Errorf("unexpected request: %+v", (*recs)[0])
+       }
+}
+
+func TestBizGetOPAPolicy_NotFoundReturnsEmpty(t *testing.T) {
+       srv, _, _ := startMockOPA(t, http.StatusNotFound, "")
+       got, err := BizGetOPAPolicy(srv.URL, "missing", "")
+       if err != nil {
+               t.Fatalf("404 should not be an error: %v", err)
+       }
+       if got != "" {
+               t.Errorf("404 should yield empty string, got %q", got)
+       }
+}
+
+func TestBizDeleteOPAPolicy_SendsDelete(t *testing.T) {
+       srv, recs, mu := startMockOPA(t, http.StatusNoContent, "")
+       if err := BizDeleteOPAPolicy(srv.URL, "p", ""); err != nil {
+               t.Fatalf("BizDeleteOPAPolicy: %v", err)
+       }
+       mu.Lock()
+       defer mu.Unlock()
+       if (*recs)[0].method != http.MethodDelete {
+               t.Errorf("want DELETE, got %s", (*recs)[0].method)
+       }
+}
+
+func TestBizDeleteOPAPolicy_NotFoundIsNil(t *testing.T) {
+       srv, _, _ := startMockOPA(t, http.StatusNotFound, "")
+       if err := BizDeleteOPAPolicy(srv.URL, "gone", ""); err != nil {
+               t.Errorf("404 on DELETE should be nil error, got %v", err)
+       }
+}
+
+// TestOPARequestTimeout_HonorsConfig is the critical test: proves that
+// adminconfig.Bootstrap.OPA.RequestTimeout actually wraps the OPA request
+// context, rather than being ignored in favor of DefaultOPAPolicyTimeout (8s)
+// or opaHTTPClient.Timeout (30s).
+func TestOPARequestTimeout_HonorsConfig(t *testing.T) {
+       setOPAHTTPClient(t, directOPAHTTPClient())
+       srv := startLoopbackHTTPServer(t, http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               time.Sleep(2 * time.Second) // far longer than configured 
timeout
+               w.WriteHeader(http.StatusOK)
+       }))
+
+       setBootstrap(t, &adminconfig.AdminBootstrap{
+               OPA: adminconfig.OPAConfig{RequestTimeout: 200 * 
time.Millisecond},
+       })
+
+       start := time.Now()
+       _, err := BizGetOPAPolicy(srv.URL, "p", "")
+       elapsed := time.Since(start)
+
+       if err == nil {
+               t.Fatal("want timeout error, got nil")
+       }
+       if !strings.Contains(err.Error(), "context deadline exceeded") {
+               t.Errorf("want 'context deadline exceeded', got %v", err)
+       }
+       // Should fire near 200ms; allow generous upper bound to avoid CI flake 
but
+       // well below the 8s default and 30s client fallback.
+       if elapsed > 1500*time.Millisecond {
+               t.Errorf("timeout fired too late (%v); config likely ignored", 
elapsed)
+       }
+       if elapsed < 150*time.Millisecond {
+               t.Errorf("timeout fired too early (%v); something other than 
config drove it", elapsed)
+       }
+}

Reply via email to