From 404defced2b3fdd1e9f61a9c0e6fec76151201d9 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 13:22:37 +0000 Subject: [PATCH] test(server): reject non-member comment and audit reads on postgres The in-memory authorizer treats any positive user id as a member, so the HTTP negative has to run against a real project row. Co-authored-by: Cursor --- .../docgov_comment_audit_nonmember_pg_test.go | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 internal/server/docgov_comment_audit_nonmember_pg_test.go diff --git a/internal/server/docgov_comment_audit_nonmember_pg_test.go b/internal/server/docgov_comment_audit_nonmember_pg_test.go new file mode 100644 index 00000000..a96e152c --- /dev/null +++ b/internal/server/docgov_comment_audit_nonmember_pg_test.go @@ -0,0 +1,103 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/authz" + "chat-agent-system/internal/bizdev" +) + +// 批注与审计的项目成员检查必须走 Postgres 授权器。 +// 内存授权器在无库时把任意正用户 id 视为成员,不能用它证明 HTTP 负例。 +func TestDocGovCommentsAndAudit_NonMemberIs403_PG(t *testing.T) { + cfg, pool := pgConfigFromTestDSN(t) + defer pool.Close() + cfg.AuthRegistrationEnabled = true + cfg.AuthRequireApproval = false + + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + srv.authorizer = authz.NewAuthorizer(pool) + + ctx := context.Background() + var projectID int64 + if err := pool.QueryRow(ctx, `SELECT id FROM projects ORDER BY id ASC LIMIT 1`).Scan(&projectID); err != nil || projectID <= 0 { + t.Skip("no projects for comment/audit negative test") + } + + docID := fmt.Sprintf("doc_h2_nonmember_%d", time.Now().UnixNano()) + if _, err := pool.Exec(ctx, ` + INSERT INTO document_records (doc_id, project_id, task_key, title) + VALUES ($1, $2, 'intake', 'H2 非成员负例') + `, docID, projectID); err != nil { + t.Fatalf("insert document: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE doc_id = $1`, docID) + }) + + uname := fmt.Sprintf("outsider_h2_%d", time.Now().UnixNano()) + pass := "OutsiderPass123!" + regBody, _ := json.Marshal(map[string]string{ + "username": uname, + "password": pass, + "name": "局外人", + }) + regReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody)) + regW := httptest.NewRecorder() + srv.Routes().ServeHTTP(regW, regReq) + if regW.Code != http.StatusOK && regW.Code != http.StatusCreated { + t.Fatalf("register outsider: %d %s", regW.Code, regW.Body.String()) + } + + loginBody, _ := json.Marshal(map[string]string{"username": uname, "password": pass}) + loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + loginW := httptest.NewRecorder() + srv.Routes().ServeHTTP(loginW, loginReq) + if loginW.Code != http.StatusOK { + t.Fatalf("outsider login: %d %s", loginW.Code, loginW.Body.String()) + } + var session auth.SessionInfo + if err := json.NewDecoder(loginW.Body).Decode(&session); err != nil || session.Token == "" || session.User == nil { + t.Fatalf("decode outsider session: %v body=%s", err, loginW.Body.String()) + } + if err := srv.authorizer.RequireProjectAccess(ctx, projectID, session.User.ID); err != authz.ErrForbidden { + t.Fatalf("outsider must not be a member of project %d: %v", projectID, err) + } + + cases := []struct { + name string + method string + target string + body []byte + }{ + {name: "list comments", method: http.MethodGet, target: "/api/v1/docgov/comments?doc_id=" + url.QueryEscape(docID)}, + {name: "list audit", method: http.MethodGet, target: "/api/v1/docgov/audit-logs?doc_id=" + url.QueryEscape(docID)}, + { + name: "add comment", + method: http.MethodPost, + target: "/api/v1/docgov/comments", + body: []byte(`{"doc_id":"` + docID + `","content":"should-fail"}`), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.target, bytes.NewReader(tc.body)) + withBearerAuth(req, session.Token) + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("%s want 403 got %d %s", tc.name, w.Code, w.Body.String()) + } + }) + } +}