You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@dubbo.apache.org by ze...@apache.org on 2026/07/26 05:33:33 UTC

(dubbo-go-pixiu) branch develop updated: add: Write unit tests (#947)

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 <yj...@gmail.com>
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)
+	}
+}