mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(plugin): return 403 when API is disabled (MUL-7437) Co-authored-by: multica-agent <[email protected]> * fix(api): classify disabled capabilities as non-retryable (MUL-7437) Co-authored-by: multica-agent <[email protected]> * fix(api): address disabled capability review feedback (MUL-7437) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]>
439 lines
18 KiB
Go
439 lines
18 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
publicapiv1 "github.com/multica-ai/multica/server/pkg/publicapi/v1"
|
|
)
|
|
|
|
// These are the Action API's security properties, not its happy path. Each one
|
|
// is a way a plugin could exceed what the person using it can do, and none of
|
|
// them is visible by reading the handler — they only hold if the three checks
|
|
// run in the right order against the right inputs.
|
|
|
|
// installPluginForAction installs the reference plugin and returns its id,
|
|
// leaving the caller free to vary scopes.
|
|
func installPluginForAction(t *testing.T, scopes []string) string {
|
|
t.Helper()
|
|
withPluginsV1Flag(t, testHandler, true)
|
|
cleanupPluginInstallations(t)
|
|
|
|
manifest := handlerTestManifest
|
|
if scopes != nil {
|
|
encoded, err := json.Marshal(scopes)
|
|
if err != nil {
|
|
t.Fatalf("encode scopes: %v", err)
|
|
}
|
|
manifest = strings.Replace(manifest,
|
|
`"scopes": ["issues:read", "comments:write", "storage:user"]`,
|
|
`"scopes": `+string(encoded), 1)
|
|
}
|
|
versionID := withLocalPluginSource(t, manifest)
|
|
|
|
body, _ := json.Marshal(map[string]any{"version_id": versionID, "granted_scopes": scopes})
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", body, map[string]string{"id": testWorkspaceID}))
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("install status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var installed struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &installed); err != nil {
|
|
t.Fatalf("decode installation: %v", err)
|
|
}
|
|
return installed.ID
|
|
}
|
|
|
|
func pluginActionRequest(method, path, installationID string, body any, params map[string]string) *http.Request {
|
|
var buf bytes.Buffer
|
|
if body != nil {
|
|
json.NewEncoder(&buf).Encode(body)
|
|
}
|
|
request := httptest.NewRequest(method, path, &buf)
|
|
request.Header.Set("X-User-ID", testUserID)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
if installationID != "" {
|
|
request.Header.Set(pluginInstallationHeader, installationID)
|
|
}
|
|
routeContext := chi.NewRouteContext()
|
|
for key, value := range params {
|
|
routeContext.URLParams.Add(key, value)
|
|
}
|
|
return request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeContext))
|
|
}
|
|
|
|
func pluginInstallTokenRequest(method, path, token string, body any, params map[string]string) *http.Request {
|
|
request := pluginActionRequest(method, path, "", body, params)
|
|
request.Header.Del("X-User-ID")
|
|
request.Header.Set("Authorization", "Bearer "+token)
|
|
return request
|
|
}
|
|
|
|
func TestPluginActionRequiresTheFeatureFlag(t *testing.T) {
|
|
withPluginsV1Flag(t, testHandler, false)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
testHandler.GetPluginContext(recorder, pluginInstallTokenRequest(http.MethodGet, "/v1/context", "mpi_invalid", nil, nil))
|
|
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("status=%d body=%s, want 403", recorder.Code, recorder.Body.String())
|
|
}
|
|
if got := recorder.Header().Get("Retry-After"); got != "" {
|
|
t.Fatalf("Retry-After=%q, want empty for a non-retryable feature gate", got)
|
|
}
|
|
var problem publicapiv1.Problem
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &problem); err != nil {
|
|
t.Fatalf("decode feature flag problem: %v", err)
|
|
}
|
|
if problem.Status != http.StatusForbidden || problem.Code != "plugin_api_disabled" || problem.Title != "Forbidden" {
|
|
t.Fatalf("unexpected feature flag problem: %+v", problem)
|
|
}
|
|
}
|
|
|
|
func TestPluginInstallTokenRunsIssueCommentWorkflow(t *testing.T) {
|
|
installationID := installPluginForAction(t, []string{"issues:read", "issues:write", "comments:read", "comments:write"})
|
|
token, err := testHandler.PluginService.IssueInstallToken(context.Background(), parseUUID(installationID))
|
|
if err != nil {
|
|
t.Fatalf("issue install token: %v", err)
|
|
}
|
|
issueID := createTestIssue(t, "Install token workflow", "todo", "none")
|
|
|
|
get := httptest.NewRecorder()
|
|
testHandler.GetPluginIssue(get, pluginInstallTokenRequest(http.MethodGet, "/v1/issues/"+issueID, token, nil,
|
|
map[string]string{"issue_ref": issueID}))
|
|
if get.Code != http.StatusOK {
|
|
t.Fatalf("install-token issue read status=%d body=%s", get.Code, get.Body.String())
|
|
}
|
|
|
|
patchRequest := pluginInstallTokenRequest(http.MethodPatch, "/v1/issues/"+issueID, token,
|
|
map[string]any{"title": "Updated by install token"}, map[string]string{"issue_ref": issueID})
|
|
patchRequest.Header.Set("If-Match", get.Header().Get("ETag"))
|
|
patch := httptest.NewRecorder()
|
|
testHandler.PatchPluginIssue(patch, patchRequest)
|
|
if patch.Code != http.StatusOK {
|
|
t.Fatalf("install-token issue patch status=%d body=%s", patch.Code, patch.Body.String())
|
|
}
|
|
|
|
create := httptest.NewRecorder()
|
|
testHandler.CreatePluginComment(create, pluginInstallTokenRequest(http.MethodPost, "/v1/issues/"+issueID+"/comments", token,
|
|
map[string]any{"content": "created with a real mpi token"}, map[string]string{"issue_ref": issueID}))
|
|
if create.Code != http.StatusCreated {
|
|
t.Fatalf("install-token comment create status=%d body=%s", create.Code, create.Body.String())
|
|
}
|
|
var comment publicapiv1.Comment
|
|
if err := json.Unmarshal(create.Body.Bytes(), &comment); err != nil {
|
|
t.Fatalf("decode install-token comment: %v", err)
|
|
}
|
|
if comment.AuthorType != "plugin" || comment.AuthorID != installationID {
|
|
t.Fatalf("install-token comment attribution = %+v", comment)
|
|
}
|
|
|
|
list := httptest.NewRecorder()
|
|
testHandler.ListPluginComments(list, pluginInstallTokenRequest(http.MethodGet, "/v1/issues/"+issueID+"/comments", token, nil,
|
|
map[string]string{"issue_ref": issueID}))
|
|
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), comment.ID) {
|
|
t.Fatalf("install-token comment list status=%d body=%s", list.Code, list.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginInstallTokenEnforcesGrantedScope(t *testing.T) {
|
|
installationID := installPluginForAction(t, []string{"issues:read"})
|
|
token, err := testHandler.PluginService.IssueInstallToken(context.Background(), parseUUID(installationID))
|
|
if err != nil {
|
|
t.Fatalf("issue install token: %v", err)
|
|
}
|
|
issueID := createTestIssue(t, "Install token scope", "todo", "none")
|
|
|
|
request := pluginInstallTokenRequest(http.MethodPatch, "/v1/issues/"+issueID, token,
|
|
map[string]any{"title": "must not update"}, map[string]string{"issue_ref": issueID})
|
|
response := httptest.NewRecorder()
|
|
testHandler.PatchPluginIssue(response, request)
|
|
if response.Code != http.StatusForbidden {
|
|
t.Fatalf("ungranted install-token patch status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
var problem publicapiv1.Problem
|
|
if err := json.Unmarshal(response.Body.Bytes(), &problem); err != nil {
|
|
t.Fatalf("decode scope problem: %v", err)
|
|
}
|
|
if problem.Code != "forbidden" || !strings.Contains(problem.Detail, "issues:write") {
|
|
t.Fatalf("unexpected scope problem: %+v", problem)
|
|
}
|
|
}
|
|
|
|
func TestPluginActionRequiresAGrantedScope(t *testing.T) {
|
|
// Installed with issues:read only. Everything else must be refused, and the
|
|
// refusal must name the missing scope so an admin can act on it.
|
|
installationID := installPluginForAction(t, []string{"issues:read"})
|
|
issueID := createTestIssue(t, "Plugin action scope test", "todo", "none")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.GetPluginIssue(recorder, pluginActionRequest(http.MethodGet, "/issues", installationID, nil,
|
|
map[string]string{"issue_ref": issueID}))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("granted scope was refused: status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.CreatePluginComment(recorder, pluginActionRequest(http.MethodPost, "/comments", installationID,
|
|
map[string]any{"content": "hello"}, map[string]string{"issue_ref": issueID}))
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("ungranted scope status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "comments:write") {
|
|
t.Fatalf("refusal does not name the missing scope: %s", recorder.Body.String())
|
|
}
|
|
var problem publicapiv1.Problem
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &problem); err != nil {
|
|
t.Fatalf("decode stable problem: %v", err)
|
|
}
|
|
if problem.Code != "forbidden" || problem.Error == "" || problem.RequestID == "" {
|
|
t.Fatalf("unexpected stable problem: %+v", problem)
|
|
}
|
|
}
|
|
|
|
func TestPluginIssueUsesStableDTOAndRevisionETag(t *testing.T) {
|
|
installationID := installPluginForAction(t, []string{"issues:read", "issues:write"})
|
|
issueID := createTestIssue(t, "Plugin public DTO test", "todo", "none")
|
|
|
|
get := httptest.NewRecorder()
|
|
testHandler.GetPluginIssue(get, pluginActionRequest(http.MethodGet, "/v1/issues/"+issueID, installationID, nil,
|
|
map[string]string{"issue_ref": issueID}))
|
|
if get.Code != http.StatusOK {
|
|
t.Fatalf("get issue status=%d body=%s", get.Code, get.Body.String())
|
|
}
|
|
var issue publicapiv1.Issue
|
|
if err := json.Unmarshal(get.Body.Bytes(), &issue); err != nil {
|
|
t.Fatalf("decode public issue: %v", err)
|
|
}
|
|
if issue.ID != issueID || issue.Identifier == "" || issue.Revision < 1 || issue.Metadata == nil || issue.Properties == nil {
|
|
t.Fatalf("unexpected public issue: %+v", issue)
|
|
}
|
|
wantETag := fmt.Sprintf(`W/"%d"`, issue.Revision)
|
|
if got := get.Header().Get("ETag"); got != wantETag {
|
|
t.Fatalf("ETag = %q, want %q", got, wantETag)
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(get.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("decode issue map: %v", err)
|
|
}
|
|
for _, appOnly := range []string{"labels", "attachments", "reactions"} {
|
|
if _, leaked := raw[appOnly]; leaked {
|
|
t.Fatalf("App-only field %q leaked into Public API: %s", appOnly, get.Body.String())
|
|
}
|
|
}
|
|
|
|
patchRequest := pluginActionRequest(http.MethodPatch, "/v1/issues/"+issueID, installationID,
|
|
map[string]any{"title": "Updated through Public API"}, map[string]string{"issue_ref": issueID})
|
|
patchRequest.Header.Set("If-Match", wantETag)
|
|
patch := httptest.NewRecorder()
|
|
testHandler.PatchPluginIssue(patch, patchRequest)
|
|
if patch.Code != http.StatusOK {
|
|
t.Fatalf("patch issue status=%d body=%s", patch.Code, patch.Body.String())
|
|
}
|
|
var updated publicapiv1.Issue
|
|
if err := json.Unmarshal(patch.Body.Bytes(), &updated); err != nil {
|
|
t.Fatalf("decode updated issue: %v", err)
|
|
}
|
|
if updated.Title != "Updated through Public API" || updated.Revision <= issue.Revision {
|
|
t.Fatalf("unexpected updated issue: %+v", updated)
|
|
}
|
|
if got := patch.Header().Get("ETag"); got != fmt.Sprintf(`W/"%d"`, updated.Revision) {
|
|
t.Fatalf("updated ETag = %q", got)
|
|
}
|
|
|
|
staleRequest := pluginActionRequest(http.MethodPatch, "/v1/issues/"+issueID, installationID,
|
|
map[string]any{"description": "stale"}, map[string]string{"issue_ref": issueID})
|
|
staleRequest.Header.Set("If-Match", wantETag)
|
|
stale := httptest.NewRecorder()
|
|
testHandler.PatchPluginIssue(stale, staleRequest)
|
|
if stale.Code != http.StatusConflict {
|
|
t.Fatalf("stale patch status=%d body=%s", stale.Code, stale.Body.String())
|
|
}
|
|
var conflict publicapiv1.Problem
|
|
if err := json.Unmarshal(stale.Body.Bytes(), &conflict); err != nil {
|
|
t.Fatalf("decode revision conflict: %v", err)
|
|
}
|
|
if conflict.Code != "revision_conflict" {
|
|
t.Fatalf("conflict code = %q", conflict.Code)
|
|
}
|
|
}
|
|
|
|
func TestPluginIssueConditionalPatchHasSingleWinner(t *testing.T) {
|
|
installationID := installPluginForAction(t, []string{"issues:read", "issues:write"})
|
|
issueID := createTestIssue(t, "Plugin conditional write test", "todo", "none")
|
|
|
|
get := httptest.NewRecorder()
|
|
testHandler.GetPluginIssue(get, pluginActionRequest(http.MethodGet, "/v1/issues/"+issueID, installationID, nil,
|
|
map[string]string{"issue_ref": issueID}))
|
|
if get.Code != http.StatusOK {
|
|
t.Fatalf("get issue status=%d body=%s", get.Code, get.Body.String())
|
|
}
|
|
etag := get.Header().Get("ETag")
|
|
|
|
start := make(chan struct{})
|
|
results := make(chan int, 2)
|
|
var wg sync.WaitGroup
|
|
for _, title := range []string{"concurrent winner A", "concurrent winner B"} {
|
|
wg.Add(1)
|
|
go func(title string) {
|
|
defer wg.Done()
|
|
request := pluginActionRequest(http.MethodPatch, "/v1/issues/"+issueID, installationID,
|
|
map[string]any{"title": title}, map[string]string{"issue_ref": issueID})
|
|
request.Header.Set("If-Match", etag)
|
|
<-start
|
|
response := httptest.NewRecorder()
|
|
testHandler.PatchPluginIssue(response, request)
|
|
results <- response.Code
|
|
}(title)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
counts := map[int]int{}
|
|
for status := range results {
|
|
counts[status]++
|
|
}
|
|
if counts[http.StatusOK] != 1 || counts[http.StatusConflict] != 1 {
|
|
t.Fatalf("conditional write statuses = %v, want one 200 and one 409", counts)
|
|
}
|
|
}
|
|
|
|
func TestPluginActionRefusedWithoutAnInstallation(t *testing.T) {
|
|
installPluginForAction(t, []string{"issues:read"})
|
|
issueID := createTestIssue(t, "Plugin action installation test", "todo", "none")
|
|
|
|
// No header at all: the endpoint must not fall back to "any installed
|
|
// plugin" or to an unscoped call.
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.GetPluginIssue(recorder, pluginActionRequest(http.MethodGet, "/issues", "", nil,
|
|
map[string]string{"issue_ref": issueID}))
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("missing installation header status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
// An installation id that does not exist is a 404, never a pass-through.
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.GetPluginIssue(recorder, pluginActionRequest(http.MethodGet, "/issues",
|
|
"11111111-1111-1111-1111-111111111111", nil, map[string]string{"issue_ref": issueID}))
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("unknown installation status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginActionStopsWhenTheInstallationIsDisabled(t *testing.T) {
|
|
installationID := installPluginForAction(t, []string{"issues:read"})
|
|
issueID := createTestIssue(t, "Plugin action disable test", "todo", "none")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.DisablePlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins/disable", nil,
|
|
map[string]string{"id": testWorkspaceID, "installationId": installationID}))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("disable status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
// A surface left open in a stale tab keeps its bridge; disabling has to cut
|
|
// it off server-side or "disabled" only means "hidden".
|
|
recorder = httptest.NewRecorder()
|
|
testHandler.GetPluginIssue(recorder, pluginActionRequest(http.MethodGet, "/issues", installationID, nil,
|
|
map[string]string{"issue_ref": issueID}))
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("disabled installation status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPluginCommentIsAuthoredByTheUserAndMarkedWithThePlugin(t *testing.T) {
|
|
installationID := installPluginForAction(t, []string{"issues:read", "comments:write"})
|
|
issueID := createTestIssue(t, "Plugin action attribution test", "todo", "none")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.CreatePluginComment(recorder, pluginActionRequest(http.MethodPost, "/comments", installationID,
|
|
map[string]any{"content": "posted through a plugin"}, map[string]string{"issue_ref": issueID}))
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("create comment status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
AuthorType string `json:"author_type"`
|
|
AuthorID string `json:"author_id"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &created); err != nil {
|
|
t.Fatalf("decode comment: %v", err)
|
|
}
|
|
// Permission-wise the write is the user's...
|
|
if created.AuthorType != "member" || created.AuthorID != testUserID {
|
|
t.Fatalf("comment was not authored by the calling user: %+v", created)
|
|
}
|
|
// ...and audit-wise it stays attributable to the plugin that produced it.
|
|
var viaPlugin *string
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT via_plugin_id::text FROM comment WHERE id = $1`, created.ID).Scan(&viaPlugin); err != nil {
|
|
t.Fatalf("read via_plugin_id: %v", err)
|
|
}
|
|
if viaPlugin == nil || *viaPlugin != installationID {
|
|
t.Fatalf("via_plugin_id = %v, want %s", viaPlugin, installationID)
|
|
}
|
|
}
|
|
|
|
func TestPluginActionCannotReachAnotherWorkspacesIssue(t *testing.T) {
|
|
installationID := installPluginForAction(t, []string{"issues:read"})
|
|
|
|
// An issue id from a workspace this user is not a member of must 404 — the
|
|
// plugin inherits the user's reach and nothing more, so this is the same
|
|
// answer the ordinary issue endpoint gives.
|
|
otherWorkspaceIssue := createIssueInForeignWorkspace(t)
|
|
recorder := httptest.NewRecorder()
|
|
testHandler.GetPluginIssue(recorder, pluginActionRequest(http.MethodGet, "/issues", installationID, nil,
|
|
map[string]string{"issue_ref": otherWorkspaceIssue}))
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("cross-workspace read status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
// createIssueInForeignWorkspace seeds a workspace the test user is NOT a member
|
|
// of, so a cross-tenant read has something real to fail against rather than a
|
|
// made-up uuid that would 404 for the wrong reason.
|
|
func createIssueInForeignWorkspace(t *testing.T) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
var ownerID string
|
|
if err := testPool.QueryRow(ctx, `INSERT INTO "user" (name, email) VALUES ('Foreign Owner', $1) RETURNING id`,
|
|
"foreign-plugin-"+testUserID+"@multica.test").Scan(&ownerID); err != nil {
|
|
t.Fatalf("seed foreign user: %v", err)
|
|
}
|
|
var workspaceID string
|
|
if err := testPool.QueryRow(ctx, `INSERT INTO workspace (name, slug) VALUES ('Foreign', $1) RETURNING id`,
|
|
"foreign-plugin-"+testUserID).Scan(&workspaceID); err != nil {
|
|
t.Fatalf("seed foreign workspace: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'owner')`,
|
|
workspaceID, ownerID); err != nil {
|
|
t.Fatalf("seed foreign member: %v", err)
|
|
}
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id)
|
|
VALUES ($1, 'Foreign issue', 'todo', 'none', 'member', $2) RETURNING id`,
|
|
workspaceID, ownerID).Scan(&issueID); err != nil {
|
|
t.Fatalf("seed foreign issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE workspace_id = $1`, workspaceID)
|
|
testPool.Exec(context.Background(), `DELETE FROM member WHERE workspace_id = $1`, workspaceID)
|
|
testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, workspaceID)
|
|
testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, ownerID)
|
|
})
|
|
return issueID
|
|
}
|