Files
multica/server/internal/handler/plugin_callback_test.go
Jiayuan Zhangandmultica-agent 8ade96e671 MUL-6584: establish Public API v1 foundation (#7450)
* feat(api): establish public v1 contract foundation

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): close public API contract gaps

Co-authored-by: multica-agent <[email protected]>

* fix(issues): enforce revision guard during update

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 22:47:37 +08:00

303 lines
13 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/plugincontract"
)
// The identity model, exercised through the path that actually decides it.
//
// A hook handler calls back with a token, and the token — not the request body,
// not a header — says who the resulting writes belong to. This is the whole
// reason the callback token exists instead of handing a plugin's server the
// install token and trusting it to say who it is acting for.
func callbackRequest(token, method, path string, body any, params map[string]string) *http.Request {
var payload []byte
if body != nil {
payload, _ = json.Marshal(body)
}
request := httptest.NewRequest(method, path, bytes.NewReader(payload))
request.Header.Set("Content-Type", "application/json")
// No X-User-ID: a plugin's own server has no session. The token is the
// entire credential, which is what these tests are about.
request.Header.Set("Authorization", "Bearer "+token)
routeContext := chi.NewRouteContext()
for key, value := range params {
routeContext.URLParams.Add(key, value)
}
return request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeContext))
}
func issueCallbackToken(t *testing.T, installationID string, actor service.HookActor) string {
t.Helper()
installation, err := testHandler.PluginService.InstallationForWorkspace(
context.Background(), parseUUID(testWorkspaceID), installationID)
if err != nil {
t.Fatalf("load installation: %v", err)
}
token, err := testHandler.PluginService.Callbacks.Issue(context.Background(), service.HookInvocation{
Installation: installation,
Hook: plugincontract.Hook{Key: "summarize"},
Trigger: plugincontract.TriggerManual,
Actor: actor,
})
if err != nil {
t.Fatalf("issue callback token: %v", err)
}
return token
}
func withCallbackTokens(t *testing.T) {
t.Helper()
previous := testHandler.PluginService.Callbacks
testHandler.PluginService.Callbacks = service.NewCallbackTokens()
t.Cleanup(func() { testHandler.PluginService.Callbacks = previous })
}
// A ui/manual hook ran because a person asked. Its writes stay that person's,
// with via_plugin_id recording that a plugin produced them — permission-wise
// the write is the user's, audit-wise it stays traceable to the plugin.
func TestCallbackFromAUserTriggeredHookWritesAsTheUser(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
withCallbackTokens(t)
installationID := installHookPlugin(t)
issueID := createTestIssue(t, "Callback attribution member", "todo", "none")
token := issueCallbackToken(t, installationID, service.HookActor{Type: "member", ID: parseUUID(testUserID)})
recorder := httptest.NewRecorder()
testHandler.CreatePluginComment(recorder, callbackRequest(token, http.MethodPost,
"/v1/issues/"+issueID+"/comments",
map[string]any{"content": "posted by a manual hook"},
map[string]string{"issue_ref": issueID}))
if recorder.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
comment := latestComment(t, issueID)
if comment.AuthorType != "member" {
t.Fatalf("author_type = %q, want member: a hook the user triggered must stay attributed to them", comment.AuthorType)
}
if uuidToString(comment.AuthorID) != testUserID {
t.Fatalf("author_id = %s, want the triggering user %s", uuidToString(comment.AuthorID), testUserID)
}
if uuidToString(comment.ViaPluginID) != installationID {
t.Fatalf("via_plugin_id = %s, want %s: the plugin must stay traceable", uuidToString(comment.ViaPluginID), installationID)
}
}
// An event hook has nobody behind it. Attributing its writes to the last member
// who happened to touch the issue would be a lie the audit trail cannot undo.
func TestCallbackFromAnEventHookWritesAsThePlugin(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
withCallbackTokens(t)
installationID := installHookPlugin(t)
issueID := createTestIssue(t, "Callback attribution plugin", "todo", "none")
token := issueCallbackToken(t, installationID, service.HookActor{Type: "plugin", ID: parseUUID(installationID)})
recorder := httptest.NewRecorder()
testHandler.CreatePluginComment(recorder, callbackRequest(token, http.MethodPost,
"/v1/issues/"+issueID+"/comments",
map[string]any{"content": "posted by an event hook"},
map[string]string{"issue_ref": issueID}))
if recorder.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
comment := latestComment(t, issueID)
if comment.AuthorType != "plugin" {
t.Fatalf("author_type = %q, want plugin: an event hook must not borrow a person's identity", comment.AuthorType)
}
if uuidToString(comment.AuthorID) != installationID {
t.Fatalf("author_id = %s, want the installation %s", uuidToString(comment.AuthorID), installationID)
}
}
// A grant is good for the whole invocation, not one request. A handler that
// reads an issue and then comments on it makes two calls, and the single-use
// version of this refused the second — the failure a live run surfaced.
func TestCallbackTokenServesTheWholeInvocation(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
withCallbackTokens(t)
installationID := installHookPlugin(t)
issueID := createTestIssue(t, "Callback single use", "todo", "none")
token := issueCallbackToken(t, installationID, service.HookActor{Type: "member", ID: parseUUID(testUserID)})
first := httptest.NewRecorder()
testHandler.GetPluginIssue(first, callbackRequest(token, http.MethodGet,
"/v1/issues/"+issueID, nil, map[string]string{"issue_ref": issueID}))
if first.Code != http.StatusOK {
t.Fatalf("first use: status=%d body=%s", first.Code, first.Body.String())
}
// The read-then-write shape every non-trivial handler has.
second := httptest.NewRecorder()
testHandler.CreatePluginComment(second, callbackRequest(token, http.MethodPost,
"/v1/issues/"+issueID+"/comments",
map[string]any{"content": "and now a comment about what I read"},
map[string]string{"issue_ref": issueID}))
if second.Code != http.StatusCreated {
t.Fatalf("a handler must be able to write after reading: status=%d body=%s", second.Code, second.Body.String())
}
// Revoked once the invocation is over.
testHandler.PluginService.Callbacks.Revoke(token)
third := httptest.NewRecorder()
testHandler.GetPluginIssue(third, callbackRequest(token, http.MethodGet,
"/v1/issues/"+issueID, nil, map[string]string{"issue_ref": issueID}))
if third.Code != http.StatusForbidden {
t.Fatalf("a revoked grant must be refused: status=%d body=%s", third.Code, third.Body.String())
}
}
// storage:user is per-member state. A caller with no member has no such scope
// to resolve, and falling through would key every plugin-actor write to the
// zero UUID — one shared bucket masquerading as somebody's private one.
func TestPluginActorCannotReachUserStorage(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
withCallbackTokens(t)
installationID := installPluginForAction(t, []string{"issues:read", "comments:write", "storage:user"})
token := issueCallbackToken(t, installationID, service.HookActor{Type: "plugin", ID: parseUUID(installationID)})
recorder := httptest.NewRecorder()
testHandler.PutPluginStorage(recorder, callbackRequest(token, http.MethodPut,
"/v1/storage/user/pref",
map[string]any{"value": "x"},
map[string]string{"scope": "user", "key": "pref"}))
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s, want 403", recorder.Code, recorder.Body.String())
}
}
// /context has no scope requirement, but it must not invent a user for a caller
// that has none — a handler reading `user` would believe it is acting for
// somebody.
func TestPluginContextOmitsTheUserForAPluginActor(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
withCallbackTokens(t)
installationID := installHookPlugin(t)
token := issueCallbackToken(t, installationID, service.HookActor{Type: "plugin", ID: parseUUID(installationID)})
recorder := httptest.NewRecorder()
testHandler.GetPluginContext(recorder, callbackRequest(token, http.MethodGet, "/v1/context", nil, nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode context: %v", err)
}
if _, present := payload["user"]; present {
t.Fatalf("context carried a user for a plugin actor: %s", recorder.Body.String())
}
if payload["actor"] != "plugin" {
t.Fatalf("actor = %v, want plugin", payload["actor"])
}
}
// latestComment reads the row directly. The response body reports what the
// handler decided to return; the row is what was actually written, and
// attribution is only meaningful as stored.
func latestComment(t *testing.T, issueID string) db.Comment {
t.Helper()
var comment db.Comment
err := testPool.QueryRow(context.Background(),
`SELECT id, author_type, author_id, via_plugin_id FROM comment WHERE issue_id = $1 ORDER BY created_at DESC LIMIT 1`,
issueID,
).Scan(&comment.ID, &comment.AuthorType, &comment.AuthorID, &comment.ViaPluginID)
if err != nil {
t.Fatalf("read the written comment: %v", err)
}
return comment
}
// The narrowing that was only a comment until Fabel 5's review.
//
// A hook called about one issue has no business reaching another. Without this
// check the grant was worth every issue in the workspace its actor could see,
// for the whole five minutes it lived — and both the type's doc comment and the
// PR description claimed otherwise.
func TestCallbackTokenCannotReachAnotherIssue(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
withCallbackTokens(t)
installationID := installHookPlugin(t)
granted := createTestIssue(t, "Callback scope granted issue", "todo", "none")
other := createTestIssue(t, "Callback scope other issue", "todo", "none")
installation, err := testHandler.PluginService.InstallationForWorkspace(
context.Background(), parseUUID(testWorkspaceID), installationID)
if err != nil {
t.Fatalf("load installation: %v", err)
}
token, err := testHandler.PluginService.Callbacks.Issue(context.Background(), service.HookInvocation{
Installation: installation,
Hook: plugincontract.Hook{Key: "summarize"},
Trigger: plugincontract.TriggerManual,
Actor: service.HookActor{Type: "member", ID: parseUUID(testUserID)},
IssueID: parseUUID(granted),
})
if err != nil {
t.Fatalf("issue callback token: %v", err)
}
// The issue it was issued for: allowed.
ok := httptest.NewRecorder()
testHandler.GetPluginIssue(ok, callbackRequest(token, http.MethodGet,
"/v1/issues/"+granted, nil, map[string]string{"issue_ref": granted}))
if ok.Code != http.StatusOK {
t.Fatalf("the granted issue must be reachable: status=%d body=%s", ok.Code, ok.Body.String())
}
// Any other issue in the same workspace: refused, on both read and write.
refusedRead := httptest.NewRecorder()
testHandler.GetPluginIssue(refusedRead, callbackRequest(token, http.MethodGet,
"/v1/issues/"+other, nil, map[string]string{"issue_ref": other}))
if refusedRead.Code != http.StatusNotFound {
t.Fatalf("reading another issue must be refused: status=%d body=%s", refusedRead.Code, refusedRead.Body.String())
}
refusedWrite := httptest.NewRecorder()
testHandler.CreatePluginComment(refusedWrite, callbackRequest(token, http.MethodPost,
"/v1/issues/"+other+"/comments",
map[string]any{"content": "should never land"},
map[string]string{"issue_ref": other}))
if refusedWrite.Code != http.StatusNotFound {
t.Fatalf("commenting on another issue must be refused: status=%d body=%s", refusedWrite.Code, refusedWrite.Body.String())
}
}
// An invocation with no issue produces an unscoped grant. That is not a hole —
// it simply means there was no issue to narrow to — and the workspace and
// membership checks still apply.
func TestCallbackTokenWithoutAnIssueIsNotNarrowed(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
withCallbackTokens(t)
installationID := installHookPlugin(t)
issueID := createTestIssue(t, "Callback unscoped grant", "todo", "none")
token := issueCallbackToken(t, installationID, service.HookActor{Type: "member", ID: parseUUID(testUserID)})
recorder := httptest.NewRecorder()
testHandler.GetPluginIssue(recorder, callbackRequest(token, http.MethodGet,
"/v1/issues/"+issueID, nil, map[string]string{"issue_ref": issueID}))
if recorder.Code != http.StatusOK {
t.Fatalf("an unscoped grant must still reach the workspace: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}