From db96b649d3eb2f3eda3511537b694776b15b1e2f Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Wed, 10 Jun 2026 15:23:23 +0000 Subject: [PATCH 1/4] fix(requests): reject self-response (RC 10416) (#2525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SECURITY: Respond previously allowed the requester to approve/reject/ done their own request by passing their own identity as responder. This is a live self-approval authz gap (RC 10416). Fix: - Add ErrSelfResponse in request_store.go. - In Respond, after fetching the request, reject if responder_type + responder_id == requester_type + requester_id. - HTTP handler translates ErrSelfResponse → 400 Bad Request. Test: - TestRequests_Respond_SelfResponse_400: agent requester tries to self-approve; asserts 400 and no UPDATE fires. Local verification: - go test ./internal/handlers/ -run TestRequests_Respond -v → all green - golangci-lint run ./internal/handlers/... → 0 issues Refs #2525 Co-Authored-By: Claude Opus 4.8 --- .../internal/handlers/request_store.go | 12 +++++++++ .../internal/handlers/requests.go | 2 +- .../internal/handlers/requests_test.go | 25 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/workspace-server/internal/handlers/request_store.go b/workspace-server/internal/handlers/request_store.go index 2be454d03..3712470dc 100644 --- a/workspace-server/internal/handlers/request_store.go +++ b/workspace-server/internal/handlers/request_store.go @@ -46,6 +46,10 @@ var ErrInvalidRequestKind = errors.New("request: kind must be 'task' or 'approva // author_type is outside the user/agent enum. Callers translate to HTTP 400. var ErrInvalidRequestParty = errors.New("request: type must be 'user' or 'agent'") +// ErrSelfResponse is returned by Respond when the responder is the same party +// as the requester (self-approval / self-rejection). Callers translate to HTTP 400. +var ErrSelfResponse = errors.New("request: responder cannot be the requester") + // CreateRequestInput is the set of fields Create needs. requester_* identifies // who raised it (for a per-workspace POST that is the calling agent); recipient_* // is who must act. Detail/OrgID/Priority are optional (zero values → NULL). @@ -387,6 +391,14 @@ func (s *RequestStore) Respond(ctx context.Context, id, action, responderType, r if err != nil { return RequestRow{}, err } + + // SECURITY: prevent self-approval / self-rejection. The requester must not + // be the same party as the responder — that would let an agent approve its + // own tasks or a user self-approve their own requests (RC 10416). + if responderType == req.RequesterType && responderID == req.RequesterID { + return RequestRow{}, ErrSelfResponse + } + status, err := actionToStatus(req.Kind, action) if err != nil { return RequestRow{}, err diff --git a/workspace-server/internal/handlers/requests.go b/workspace-server/internal/handlers/requests.go index 779b7c300..264b3fdb8 100644 --- a/workspace-server/internal/handlers/requests.go +++ b/workspace-server/internal/handlers/requests.go @@ -258,7 +258,7 @@ func (h *RequestsHandler) Respond(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "request not found or already resolved"}) return } - if errors.Is(err, ErrInvalidRequestAction) || errors.Is(err, ErrInvalidRequestParty) { + if errors.Is(err, ErrInvalidRequestAction) || errors.Is(err, ErrInvalidRequestParty) || errors.Is(err, ErrSelfResponse) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/workspace-server/internal/handlers/requests_test.go b/workspace-server/internal/handlers/requests_test.go index 2e7101c4d..400501f4f 100644 --- a/workspace-server/internal/handlers/requests_test.go +++ b/workspace-server/internal/handlers/requests_test.go @@ -371,6 +371,31 @@ func TestRequests_Respond_NotFound(t *testing.T) { } } +// TestRequests_Respond_SelfResponse_400 pins RC 10416: the requester must not +// be able to approve/reject/done their own request. +func TestRequests_Respond_SelfResponse_400(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewRequestsHandler(newTestBroadcaster()) + + // Requester is agent ws-1; responder is also agent ws-1 → self-response. + mock.ExpectQuery("FROM requests WHERE id"). + WithArgs("req-1"). + WillReturnRows(oneRequestRow("req-1", "approval", "ws-1", "user", "", "pending")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "requestId", Value: "req-1"}} + c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"action":"approved","responder_type":"agent","responder_id":"ws-1"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Respond(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for self-response, got %d: %s", w.Code, w.Body.String()) + } +} + // ---------- AddMessage → info_requested when recipient asks ---------- func TestRequests_AddMessage_RecipientFlipsInfoRequested(t *testing.T) { -- 2.52.0 From 2617a29d2231b4f242682a4ccd1e8c99b16c9666 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Wed, 10 Jun 2026 15:56:26 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(requests):=20REAL=20participant-binding?= =?UTF-8?q?=20on=20Respond=20=E2=80=94=20bind=20agent-path=20responder=20t?= =?UTF-8?q?o=20authenticated=20workspace=20(core#2542)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Respond endpoint previously accepted responder_type + responder_id from the request body on BOTH the workspace-token path and the canvas/admin path. This allowed an authenticated workspace to impersonate a user or a different agent when responding to a request, bypassing the store-level self-response guard (RC 10416). REAL participant-binding: - On the workspace-token auth path (/workspaces/:id/requests/...), the responder is now BOUND to the authenticated workspace from the URL param. The body fields are ignored. - The canvas/admin path continues to take responder from the body (defaults to 'user'). - The store-level ErrSelfResponse guard remains as defense-in-depth. Tests: - TestRequests_Respond_AgentPath_BindsWorkspace: body claims self-response, but URL workspace is different → binding overrides → 200. - TestRequests_Respond_AgentPath_SelfResponse_400: URL workspace matches requester → bound self-response → still 400. Refs core#2542, RC 10416 --- .../internal/handlers/requests.go | 21 +++++- .../internal/handlers/requests_test.go | 66 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/workspace-server/internal/handlers/requests.go b/workspace-server/internal/handlers/requests.go index 264b3fdb8..bc7fe2d81 100644 --- a/workspace-server/internal/handlers/requests.go +++ b/workspace-server/internal/handlers/requests.go @@ -228,8 +228,14 @@ func (h *RequestsHandler) Get(c *gin.Context) { } // Respond handles POST /requests/:requestId/respond — a terminal action -// (done/rejected/approved), validated against the request's kind. responder -// identity comes from the body; the canvas/admin path defaults to 'user'. +// (done/rejected/approved), validated against the request's kind. +// +// REAL participant-binding (RC 10416 follow-up): on the workspace-token auth +// path (/workspaces/:id/requests/...), the responder is BOUND to the +// authenticated workspace — the body fields are ignored. This prevents a +// workspace from impersonating a user or a different agent when responding. +// The canvas/admin path continues to take responder from the body (defaults +// to 'user' on the canvas). // // @Summary Respond to a request (done / rejected / approved) // @Tags requests @@ -253,7 +259,16 @@ func (h *RequestsHandler) Respond(c *gin.Context) { return } - if _, err := h.store().Respond(ctx, requestID, body.Action, body.ResponderType, body.ResponderID); err != nil { + responderType := body.ResponderType + responderID := body.ResponderID + + // Workspace-token auth path: bind responder to the authenticated workspace. + if workspaceID := c.Param("id"); workspaceID != "" { + responderType = "agent" + responderID = workspaceID + } + + if _, err := h.store().Respond(ctx, requestID, body.Action, responderType, responderID); err != nil { if errors.Is(err, ErrRequestNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "request not found or already resolved"}) return diff --git a/workspace-server/internal/handlers/requests_test.go b/workspace-server/internal/handlers/requests_test.go index 400501f4f..674c7de73 100644 --- a/workspace-server/internal/handlers/requests_test.go +++ b/workspace-server/internal/handlers/requests_test.go @@ -396,6 +396,72 @@ func TestRequests_Respond_SelfResponse_400(t *testing.T) { } } +// TestRequests_Respond_AgentPath_BindsWorkspace verifies REAL participant-binding: +// on the workspace-token auth path the responder is forced to the URL workspace, +// ignoring any impersonation attempt in the body. +func TestRequests_Respond_AgentPath_BindsWorkspace(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewRequestsHandler(newTestBroadcaster()) + + // Requester is agent ws-1; body claims self-response (ws-1), but the URL + // workspace is ws-2. Binding overrides the body, so responder = ws-2 and + // the call succeeds (not self-response). + mock.ExpectQuery("FROM requests WHERE id"). + WithArgs("req-1"). + WillReturnRows(oneRequestRow("req-1", "approval", "ws-1", "agent", "ws-2", "pending")) + mock.ExpectExec("UPDATE requests SET status"). + WithArgs("approved", "agent", "ws-2", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO structure_events"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "requestId", Value: "req-1"}, + {Key: "id", Value: "ws-2"}, + } + c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"action":"approved","responder_type":"agent","responder_id":"ws-1"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Respond(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 when bound responder differs from requester, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestRequests_Respond_AgentPath_SelfResponse_400 verifies that binding does +// NOT bypass the self-response guard: when the URL workspace IS the requester, +// the response is still rejected. +func TestRequests_Respond_AgentPath_SelfResponse_400(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewRequestsHandler(newTestBroadcaster()) + + // Requester is agent ws-1; URL workspace is also ws-1 → bound responder is + // the requester → self-response. + mock.ExpectQuery("FROM requests WHERE id"). + WithArgs("req-1"). + WillReturnRows(oneRequestRow("req-1", "approval", "ws-1", "agent", "ws-2", "pending")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "requestId", Value: "req-1"}, + {Key: "id", Value: "ws-1"}, + } + c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"action":"approved","responder_type":"agent","responder_id":"ws-2"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Respond(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for bound self-response, got %d: %s", w.Code, w.Body.String()) + } +} + // ---------- AddMessage → info_requested when recipient asks ---------- func TestRequests_AddMessage_RecipientFlipsInfoRequested(t *testing.T) { -- 2.52.0 From a66b7a007b6e83ec86d2739f991c3806392345f7 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Wed, 10 Jun 2026 16:55:09 +0000 Subject: [PATCH 3/4] fix(requests): FULL cross-tenant authz for Respond + Cancel (core#2542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (2617a29d) bound the responder to the authenticated workspace on the agent path, but did not verify the caller was actually the intended recipient of the request. This left cross-workspace respond/cancel open — any authenticated workspace could terminally act on any request. FULL fix: - Respond (agent path): after binding responder to the URL workspace, fetch the request and verify workspaceID == recipient. Returns 403 if the caller is not the recipient. - Cancel (agent path): fetch the request and verify workspaceID == requester. Returns 403 if the caller is not the requester. - Canvas/admin paths remain unchanged (no :id param → skip authz). Tests: - TestRequests_Respond_AgentPath_NotRecipient_403 - TestRequests_Respond_AgentPath_SelfResponse_400 (updated to self-addressed request scenario) - TestRequests_Cancel_AgentPath_NotRequester_403 Refs core#2542, RC 10416 --- .../internal/handlers/requests.go | 40 ++++++++++ .../internal/handlers/requests_test.go | 74 +++++++++++++++++-- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/workspace-server/internal/handlers/requests.go b/workspace-server/internal/handlers/requests.go index bc7fe2d81..df998c487 100644 --- a/workspace-server/internal/handlers/requests.go +++ b/workspace-server/internal/handlers/requests.go @@ -268,6 +268,26 @@ func (h *RequestsHandler) Respond(c *gin.Context) { responderID = workspaceID } + // Workspace-token auth path: verify the caller is the request's recipient. + // Cross-workspace respond is forbidden — an agent must not terminate a + // request that was not addressed to it (core#2542 full fix). + if workspaceID := c.Param("id"); workspaceID != "" { + reqRow, err := h.store().Get(ctx, requestID) + if err != nil { + if errors.Is(err, ErrRequestNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "request not found or already resolved"}) + return + } + log.Printf("Respond authz error request=%s: %v", requestID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to respond"}) + return + } + if reqRow.RecipientType != "agent" || reqRow.RecipientID != workspaceID { + c.JSON(http.StatusForbidden, gin.H{"error": "not the recipient"}) + return + } + } + if _, err := h.store().Respond(ctx, requestID, body.Action, responderType, responderID); err != nil { if errors.Is(err, ErrRequestNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "request not found or already resolved"}) @@ -344,6 +364,26 @@ func (h *RequestsHandler) Cancel(c *gin.Context) { requestID := c.Param("requestId") ctx := c.Request.Context() + // Workspace-token auth path: verify the caller is the request's requester. + // Cross-workspace cancel is forbidden — an agent must not withdraw a + // request it did not raise (core#2542 full fix). + if workspaceID := c.Param("id"); workspaceID != "" { + reqRow, err := h.store().Get(ctx, requestID) + if err != nil { + if errors.Is(err, ErrRequestNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "request not found or already resolved"}) + return + } + log.Printf("Cancel authz error request=%s: %v", requestID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel"}) + return + } + if reqRow.RequesterType != "agent" || reqRow.RequesterID != workspaceID { + c.JSON(http.StatusForbidden, gin.H{"error": "not the requester"}) + return + } + } + if err := h.store().Cancel(ctx, requestID); err != nil { if errors.Is(err, ErrRequestNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "request not found or already resolved"}) diff --git a/workspace-server/internal/handlers/requests_test.go b/workspace-server/internal/handlers/requests_test.go index 674c7de73..673e1233d 100644 --- a/workspace-server/internal/handlers/requests_test.go +++ b/workspace-server/internal/handlers/requests_test.go @@ -407,6 +407,10 @@ func TestRequests_Respond_AgentPath_BindsWorkspace(t *testing.T) { // Requester is agent ws-1; body claims self-response (ws-1), but the URL // workspace is ws-2. Binding overrides the body, so responder = ws-2 and // the call succeeds (not self-response). + // Authz Get in handler, then store Get inside Respond. + mock.ExpectQuery("FROM requests WHERE id"). + WithArgs("req-1"). + WillReturnRows(oneRequestRow("req-1", "approval", "ws-1", "agent", "ws-2", "pending")) mock.ExpectQuery("FROM requests WHERE id"). WithArgs("req-1"). WillReturnRows(oneRequestRow("req-1", "approval", "ws-1", "agent", "ws-2", "pending")) @@ -432,16 +436,14 @@ func TestRequests_Respond_AgentPath_BindsWorkspace(t *testing.T) { } } -// TestRequests_Respond_AgentPath_SelfResponse_400 verifies that binding does -// NOT bypass the self-response guard: when the URL workspace IS the requester, -// the response is still rejected. -func TestRequests_Respond_AgentPath_SelfResponse_400(t *testing.T) { +// TestRequests_Respond_AgentPath_NotRecipient_403 verifies that an agent +// cannot respond to a request that was not addressed to it (core#2542). +func TestRequests_Respond_AgentPath_NotRecipient_403(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) handler := NewRequestsHandler(newTestBroadcaster()) - // Requester is agent ws-1; URL workspace is also ws-1 → bound responder is - // the requester → self-response. + // Requester is ws-1, recipient is ws-2; URL workspace is ws-1 (not recipient). mock.ExpectQuery("FROM requests WHERE id"). WithArgs("req-1"). WillReturnRows(oneRequestRow("req-1", "approval", "ws-1", "agent", "ws-2", "pending")) @@ -457,6 +459,39 @@ func TestRequests_Respond_AgentPath_SelfResponse_400(t *testing.T) { handler.Respond(c) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-recipient respond, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestRequests_Respond_AgentPath_SelfResponse_400 verifies that the +// self-response guard still fires on the agent path when the request is +// self-addressed (requester == recipient) and the caller tries to respond. +func TestRequests_Respond_AgentPath_SelfResponse_400(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewRequestsHandler(newTestBroadcaster()) + + // Self-addressed request: requester = recipient = ws-1. + // Authz Get in handler, then store Get inside Respond. + mock.ExpectQuery("FROM requests WHERE id"). + WithArgs("req-1"). + WillReturnRows(oneRequestRow("req-1", "approval", "ws-1", "agent", "ws-1", "pending")) + mock.ExpectQuery("FROM requests WHERE id"). + WithArgs("req-1"). + WillReturnRows(oneRequestRow("req-1", "approval", "ws-1", "agent", "ws-1", "pending")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "requestId", Value: "req-1"}, + {Key: "id", Value: "ws-1"}, + } + c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"action":"approved","responder_type":"agent","responder_id":"ws-1"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Respond(c) + if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for bound self-response, got %d: %s", w.Code, w.Body.String()) } @@ -550,6 +585,33 @@ func TestRequests_Cancel_Success(t *testing.T) { } } +// TestRequests_Cancel_AgentPath_NotRequester_403 verifies that an agent +// cannot cancel a request it did not raise (core#2542). +func TestRequests_Cancel_AgentPath_NotRequester_403(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewRequestsHandler(newTestBroadcaster()) + + // Requester is ws-1; URL workspace is ws-2 (not requester). + mock.ExpectQuery("FROM requests WHERE id"). + WithArgs("req-1"). + WillReturnRows(oneRequestRow("req-1", "task", "ws-1", "agent", "ws-2", "pending")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "requestId", Value: "req-1"}, + {Key: "id", Value: "ws-2"}, + } + c.Request = httptest.NewRequest("POST", "/", nil) + + handler.Cancel(c) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-requester cancel, got %d: %s", w.Code, w.Body.String()) + } +} + func TestRequests_Cancel_NotFound(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) -- 2.52.0 From 70d3768ab9ec1ad03217145f7a89ac57c948d515 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Wed, 10 Jun 2026 21:47:08 +0000 Subject: [PATCH 4/4] fix(requests): membership-scope Get on workspace path + cross-org read test (core#2542) RequestsHandler.Get was org-unscoped: any authenticated workspace could GET another org's request by id and receive its full detail + Messages. Fix: - On the workspace-token auth path (c.Param("id") != ""), 403 unless the caller is either the requester or the recipient. - Three new tests: requester-read 200, recipient-read 200, non-participant 403. This closes the read-half of the cross-tenant incident, satisfying the agent-reviewer 2nd-lane requirement for FULL closure. Co-Authored-By: Claude Opus 4.8 --- .../internal/handlers/requests.go | 13 +++ .../internal/handlers/requests_test.go | 85 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/workspace-server/internal/handlers/requests.go b/workspace-server/internal/handlers/requests.go index df998c487..992932dc0 100644 --- a/workspace-server/internal/handlers/requests.go +++ b/workspace-server/internal/handlers/requests.go @@ -218,6 +218,19 @@ func (h *RequestsHandler) Get(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } + + // Workspace-token auth path: a workspace may read only a request it is a + // party to (requester or recipient). Cross-workspace read is forbidden + // (core#2542 full fix). + if workspaceID := c.Param("id"); workspaceID != "" { + isParty := (req.RequesterType == "agent" && req.RequesterID == workspaceID) || + (req.RecipientType == "agent" && req.RecipientID == workspaceID) + if !isParty { + c.JSON(http.StatusForbidden, gin.H{"error": "not a participant"}) + return + } + } + msgs, err := s.Messages(ctx, requestID) if err != nil { log.Printf("Get request messages error request=%s: %v", requestID, err) diff --git a/workspace-server/internal/handlers/requests_test.go b/workspace-server/internal/handlers/requests_test.go index 673e1233d..830e2f1e4 100644 --- a/workspace-server/internal/handlers/requests_test.go +++ b/workspace-server/internal/handlers/requests_test.go @@ -265,6 +265,91 @@ func TestRequests_Get_NotFound(t *testing.T) { } } +// TestRequests_Get_AgentPath_Requester_200 verifies that the requester workspace +// can read its own request on the workspace-token auth path. +func TestRequests_Get_AgentPath_Requester_200(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewRequestsHandler(newTestBroadcaster()) + + mock.ExpectQuery("FROM requests WHERE id"). + WithArgs("req-1"). + WillReturnRows(oneRequestRow("req-1", "task", "ws-1", "agent", "ws-2", "pending")) + mock.ExpectQuery("FROM request_messages WHERE request_id"). + WithArgs("req-1"). + WillReturnRows(sqlmock.NewRows([]string{"id", "request_id", "author_type", "author_id", "body", "created_at"})) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "requestId", Value: "req-1"}, + {Key: "id", Value: "ws-1"}, + } + c.Request = httptest.NewRequest("GET", "/", nil) + + handler.Get(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 for requester read, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestRequests_Get_AgentPath_Recipient_200 verifies that the recipient workspace +// can read a request addressed to it on the workspace-token auth path. +func TestRequests_Get_AgentPath_Recipient_200(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewRequestsHandler(newTestBroadcaster()) + + mock.ExpectQuery("FROM requests WHERE id"). + WithArgs("req-1"). + WillReturnRows(oneRequestRow("req-1", "approval", "ws-1", "agent", "ws-2", "pending")) + mock.ExpectQuery("FROM request_messages WHERE request_id"). + WithArgs("req-1"). + WillReturnRows(sqlmock.NewRows([]string{"id", "request_id", "author_type", "author_id", "body", "created_at"})) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "requestId", Value: "req-1"}, + {Key: "id", Value: "ws-2"}, + } + c.Request = httptest.NewRequest("GET", "/", nil) + + handler.Get(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 for recipient read, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestRequests_Get_AgentPath_NonParticipant_403 verifies that a workspace that +// is neither the requester nor the recipient gets 403 on the workspace-token +// auth path (core#2542 full fix — read-path org-scoping). +func TestRequests_Get_AgentPath_NonParticipant_403(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + handler := NewRequestsHandler(newTestBroadcaster()) + + mock.ExpectQuery("FROM requests WHERE id"). + WithArgs("req-1"). + WillReturnRows(oneRequestRow("req-1", "task", "ws-1", "agent", "ws-2", "pending")) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "requestId", Value: "req-1"}, + {Key: "id", Value: "ws-3"}, + } + c.Request = httptest.NewRequest("GET", "/", nil) + + handler.Get(c) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-participant read, got %d: %s", w.Code, w.Body.String()) + } +} + // ---------- Respond (valid + invalid action-for-kind) ---------- func TestRequests_Respond_ApprovalApproved(t *testing.T) { -- 2.52.0