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 := ®oMockOPA{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)
+ }
+}