From 430caa6296ca7832dc709a73a6822961543ab4ea Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 12:04:43 +0000 Subject: [PATCH] Add red tests for EXT-8b read-path consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: U9 第二切片——server 读面须按资源级六要素咨询 V2(红:SetResourceAuthorizerV2 未定义)。 Evidence: go vet 编译红(槽位/消费缺失);断言下载/list/建会话三路径查询要素、V2 拒绝压过 V1、V2 缺席回落 V1。 Impact: 仅测试。 Refs: Backend#178;方案 r1 U9 --- .../server/resource_authz_consumption_test.go | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 internal/server/resource_authz_consumption_test.go diff --git a/internal/server/resource_authz_consumption_test.go b/internal/server/resource_authz_consumption_test.go new file mode 100644 index 0000000..e800636 --- /dev/null +++ b/internal/server/resource_authz_consumption_test.go @@ -0,0 +1,129 @@ +package server + +// EXT-8b 读面消费红测(方案 r1 U9 第二切片):五处共享读路径在 V2 注册后按 +// 资源级查询咨询(Actor/Action/ResourceType/ResourceID/Owner/ProjectID 由 +// 受信上下文构造);V2 拒绝压过 V1 放行(资源级权威);V2 未注册时五处全部 +// 回落 V1(兼容现行,RDM 现网行为不变)。红:当前 server 尚无 V2 槽位与 +// AuthorizeResource 消费——注册接口不存在,编译红先行。 + +import ( + "net/http" + "net/http/httptest" + "testing" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +func newV2CaptureServer(t *testing.T) (*Server, *store.Store, string, string, string) { + t.Helper() + srv, st := newAuthTestServer(t) + alice, err := st.CreateAPIKey("alice", "e2e") + if err != nil { + t.Fatal(err) + } + bob, err := st.CreateAPIKey("bob", "e2e") + if err != nil { + t.Fatal(err) + } + pj, err := st.CreateProject("alice", "资源级项目") + if err != nil { + t.Fatal(err) + } + return srv, st, alice, bob, pj.ID +} + +func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) { + srv, st, alice, bob, pid := newV2CaptureServer(t) + _ = alice + + // 项目内造一个工件(供 download 路径取元数据)。 + art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "res.txt", Kind: "file", MIME: "text/plain"} + if err := st.CreateArtifact(art, []byte("body")); err != nil { + t.Fatal(err) + } + + var got []extension.ResourceAuthzQuery + srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + got = append(got, q) + return true, nil + }) + h := srv.Handler() + call := func(method, path string) int { + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Authorization", "Bearer "+bob) + rec := httptest.NewRecorder() + h.ServeHTTP(&rec, req) + return rec.Code + } + + // ① 工件下载(owner 自有未命中 → 资源级咨询)。 + code := call(http.MethodGet, "/api/projects/"+pid+"/artifacts/"+string(art.ID)+"/download") + if code != http.StatusOK { + t.Fatalf("download: %d", code) + } + // ② 项目工件清单。 + call(http.MethodGet, "/api/projects/"+pid+"/artifacts") + // ③ 共享项目建会话。 + call(http.MethodPost, "/api/projects/"+pid+"/conversations", nil) + + byAction := map[string]extension.ResourceAuthzQuery{} + for _, q := range got { + byAction[q.Action] = q + } + dl := byAction["download"] + if dl.Actor != "bob" || dl.ResourceType != "artifact" || dl.ResourceID != art.ID || dl.ProjectID != pid || dl.Owner != "alice" { + t.Fatalf("download 查询六要素不符: %+v", dl) + } + ls := byAction["list"] + if ls.Actor != "bob" || ls.ResourceType != "project" || ls.ResourceID != pid || ls.ProjectID != pid { + t.Fatalf("list 查询不符: %+v", ls) + } + rd := byAction["read"] + if rd.Actor != "bob" || rd.ResourceType != "project" || rd.ResourceID != pid { + t.Fatalf("read(conversation) 查询不符: %+v", rd) + } +} + +func TestV2DenyOverridesV1Allow(t *testing.T) { + srv, st, alice, bob, pid := newV2CaptureServer(t) + _ = alice + art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "d.txt", Kind: "file"} + if err := st.CreateArtifact(art, []byte("body")); err != nil { + t.Fatal(err) + } + // V1 全放行、V2 全拒绝——资源级权威必须压过 V1。 + srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { + return true, nil + }) + srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return false, nil + }) + req := httptest.NewRequest(http.MethodGet, "/api/projects/"+pid+"/artifacts/"+string(art.ID)+"/download", nil) + req.Header.Set("Authorization", "Bearer "+bob) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(&rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("V2 拒绝须压过 V1 放行并保持 404(不泄露存在性), got %d", rec.Code) + } +} + +func TestV2AbsentFallsBackToV1(t *testing.T) { + srv, st, alice, bob, pid := newV2CaptureServer(t) + _ = alice + art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "f.txt", Kind: "file"} + if err := st.CreateArtifact(art, []byte("body")); err != nil { + t.Fatal(err) + } + srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { + return owner == "bob" && string(projectID) == pid, nil + }) + req := httptest.NewRequest(http.MethodGet, "/api/projects/"+pid+"/artifacts/"+string(art.ID)+"/download", nil) + req.Header.Set("Authorization", "Bearer "+bob) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(&rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("V2 未注册应回落 V1(现行行为), got %d", rec.Code) + } +}