AlexStocks commented on code in PR #947: URL: https://github.com/apache/dubbo-go-pixiu/pull/947#discussion_r3503797400
########## admin/logic/opa_test.go: ########## @@ -0,0 +1,257 @@ +/* + * 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/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 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() + var ( + mu sync.Mutex + recs []recordedRequest + ) + srv := httptest.NewServer(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)) + } + })) + t.Cleanup(srv.Close) + return srv, &recs, &mu +} + +func TestBizPutOPAPolicy_SendsCorrectRequest(t *testing.T) { Review Comment: [P1] 这组测试当前在真实执行里并不成立。我本地运行 `go test ./admin/config ./admin/controller/opa ./admin/logic ./admin/initialize -count=1` 时,从这里开始的多个用例都会失败,错误统一是 `OPA connection failed` / `context deadline exceeded`,也就是业务层 HTTP 客户端并不能稳定访问这里起的 `httptest.Server`。在这种情况下,这些测试无法作为可合入的回归保障。建议先把测试使用的服务端协议/监听地址与业务侧客户端的真实拨号方式对齐,再提交这批用例。 ########## admin/initialize/router_opa_test.go: ########## @@ -0,0 +1,397 @@ +/* + * 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() + m := &mockOPA{policies: map[string]string{}} + m.srv = httptest.NewServer(http.HandlerFunc(m.handle)) + t.Cleanup(m.srv.Close) + 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 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() Review Comment: [P1] 这里在上游调用成功之前就直接读 `recs[0]`。我本地真实执行时这个请求并没有成功打到 mock OPA,`m.records()` 为空,随后这里会触发 `index out of range [0] with length 0` panic,把整个 `admin/initialize` 包测试打崩。即使只是测试代码,也不能在前置断言没成立时直接越界读取。建议先断言 `len(recs) == 1`,失败时直接 `t.Fatalf`,再检查 method/path。 ########## admin/initialize/e2e_opa_test.go: ########## @@ -0,0 +1,626 @@ +/* + * 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" + "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() + m := ®oMockOPA{policies: map[string]string{}} + m.srv = httptest.NewServer(http.HandlerFunc(m.handle)) + t.Cleanup(m.srv.Close) + 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, policyID, content string) { + t.Helper() + fields := map[string]string{"content": content} + 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()) + } +} + +// adminDeletePolicy hits DELETE /config/api/opa/policy. +func adminDeletePolicy(t *testing.T, r *gin.Engine, policyID string) { + t.Helper() + url := "/config/api/opa/policy" + if policyID != "" { + url = url + "?policy_id=" + policyID + } + req := httptest.NewRequest(http.MethodDelete, url, 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, e2ePolicyID, allowGETPolicy) Review Comment: [P1] 这条以及后面同类 E2E 用例在当前分支本地执行都会先失败在 `adminPutPolicy(...)`,返回体里是 `OPA connection failed: Put ... connectex ...`,也就是还没进入后面的 gateway/filter 断言。这样它们现在验证不到“admin → OPA → gateway”链路,只是在当前环境下稳定报连接失败。建议先把 admin 侧写策略这一步做成可稳定通过的前置条件,再保留这些 E2E 断言;否则这批用例会把 CI 常态化打红。 -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
