Files
multica/server/internal/handler/plugin_hook_test.go
686957e743 MUL-7271: test(server): cut slow Go test time further (follow-up to #8295) (#8318)
* test(server): reduce slow unit test waits

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

* test(server): cut slow Go unit test time further

Follow-up to the first pass on MUL-7271. Test-only changes plus a few
package-private seams whose production values are unchanged.

- Re-executed race-built test binaries no longer pay the race runtime's 1s
  atexit sleep (GORACE=atexit_sleep_ms=0 for child processes only).
- pkg/agent: event-driven or shorter test-only bounds for handshake, drain
  and idle graces; redundant timeout variants merged.
- daemon/execenv: git fixtures copied from a template repo built once; tests
  that re-ran the same worktree lifecycle folded into their supersets;
  ForkLock-guarded fake-CLI writes so parallel tests cannot hit ETXTBSY.
- handler/service: cap and paging tests shrink their caps instead of seeding
  thousands of rows; lock waits probe pg_locks instead of sleeping; one
  shared pgxpool per service test binary; cheaper cron and RSA fixtures.
- wecom: the phishing-link sweep reuses one parser and derives CRLF from LF
  with a sampled cross-check; fake sockets acknowledge instead of waiting out
  the ack timeout.
- migrate: the migration corpus is parsed once; isolated DB tests run in
  parallel.

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

* test(server): remove transport timeout unit tests

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

* test(server): restore two non-timeout tests and make a bound load-bearing

Follow-up to the review of #8318. All test-only; no production code changes.

- daemon: restore the three read-liveness cases that the transport-timeout
  sweep took with it. They assert that a ping, a pong and an application
  frame each EXTEND the read deadline, so a healthy wakeup socket survives
  without traffic of our own — the deadline never fires on the happy path,
  which is why they are not transport-timeout tests. Verified they fail
  (all three) when the extension calls are removed from wakeup.go, and pass
  in 0.57s together. The pure "times out without peer traffic" case stays
  deleted.

- pkg/agent: restore the parent-DEADLINE half of the opaque-env redaction
  pair. codex.go treats a parent deadline and a parent cancellation as one
  ending; only the Canceled arm was left covered. The fake now writes the
  secret to stderr before reading the handshake, so the check no longer
  races our own initialize write, and the budget is 2s against a measured
  430-465ms launch. Verified it fails when the !contextEnded guard is
  removed.

- pkg/agent: TestRunCollectQuietDoesNotWaitWhenTheAnswerIsIn had its bound
  widened to a literal 5s, which the shrunk 750ms drain grace can never
  reach — the test could no longer fail. It now raises the drain grace to
  10s for its own duration and asserts under 3s: healthy runs take
  0.19-0.44s, and a broken short-circuit takes 10.19s and fails.

- wecom: gofmt the import block in relay_settle_budget_test.go.

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: J <[email protected]>
2026-09-14 14:33:50 +08:00

421 lines
17 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"github.com/multica-ai/multica/server/internal/service"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/featureflags"
"github.com/multica-ai/multica/server/pkg/featureflag"
"github.com/multica-ai/multica/server/pkg/plugincontract"
)
// hookHandlerTestManifest declares the three host-driven triggers. Its endpoint
// is never reached in these tests — they cover the checks BEFORE the request
// leaves, which is where a wrong answer is a security bug rather than a broken
// integration. The wire format and signature are covered end-to-end against a
// live server in internal/service.
const hookHandlerTestManifest = `{
"manifest_version": 1,
"key": "com.example.hooked",
"name": "Hooked",
"version": "1.0.0",
"author": { "name": "example" },
"scopes": ["issues:read", "comments:write", "net:example.com"],
"contributes": {
"hooks": [{
"key": "summarize",
"name": "Summarize",
"description": "Compress the discussion.",
"triggers": ["ui", "manual", "event"],
"events": ["issue.created"],
"transport": { "type": "http", "url": "https://example.com/hooks/summarize" }
}, {
"key": "manual_only",
"name": "Manual only",
"description": "Only ever picked from a menu.",
"triggers": ["manual"],
"transport": { "type": "http", "url": "https://example.com/hooks/manual" }
}]
}
}`
func installHookPlugin(t *testing.T) string {
t.Helper()
versionID := withLocalPluginSource(t, hookHandlerTestManifest)
body, _ := json.Marshal(map[string]any{
"version_id": versionID,
"granted_scopes": []string{"issues:read", "comments:write", "net:example.com"},
})
recorder := httptest.NewRecorder()
testHandler.InstallPlugin(recorder, pluginHandlerRequest(http.MethodPost, "/plugins", body, map[string]string{"id": testWorkspaceID}))
if recorder.Code != http.StatusCreated {
t.Fatalf("install hook plugin: 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 invokeHookRequest(installationID, hookKey string, payload map[string]any) *http.Request {
body, _ := json.Marshal(payload)
request := httptest.NewRequest(http.MethodPost, "/api/plugin-bridge/v1/hooks/"+hookKey, bytes.NewReader(body))
request.Header.Set("X-User-ID", testUserID)
request.Header.Set("Content-Type", "application/json")
request.Header.Set(pluginInstallationHeader, installationID)
routeContext := chi.NewRouteContext()
routeContext.URLParams.Add("key", hookKey)
return request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeContext))
}
// `event` is dispatched by the host, off the event bus, precisely so that its
// writes carry the plugin's identity rather than a person's. A browser asking
// for one would be electing to run as somebody it is not.
func TestInvokePluginHookRefusesHostDrivenTriggers(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
installationID := installHookPlugin(t)
for _, trigger := range []string{plugincontract.TriggerEvent, plugincontract.TriggerAgent, "", "nonsense"} {
recorder := httptest.NewRecorder()
testHandler.InvokePluginHook(recorder, invokeHookRequest(installationID, "summarize", map[string]any{"trigger": trigger}))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("trigger %q: status=%d body=%s, want 400", trigger, recorder.Code, recorder.Body.String())
}
}
}
// A hook may only be reached through a trigger its own manifest declared, even
// when the host supports that trigger in general.
func TestInvokePluginHookRefusesTriggerTheManifestDidNotDeclare(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
installationID := installHookPlugin(t)
recorder := httptest.NewRecorder()
// manual_only declares manual, not ui.
testHandler.InvokePluginHook(recorder, invokeHookRequest(installationID, "manual_only", map[string]any{"trigger": "ui"}))
if recorder.Code == http.StatusOK {
t.Fatalf("a hook was invoked through a trigger it never declared: %s", recorder.Body.String())
}
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s, want 403", recorder.Code, recorder.Body.String())
}
}
func TestInvokePluginHookRefusesUnknownHook(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
installationID := installHookPlugin(t)
recorder := httptest.NewRecorder()
testHandler.InvokePluginHook(recorder, invokeHookRequest(installationID, "not_declared", map[string]any{"trigger": "manual"}))
if recorder.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%s, want 404", recorder.Code, recorder.Body.String())
}
}
// The flag gates the hook endpoint like every other plugin route: fail closed,
// not merely hidden from the UI.
func TestInvokePluginHookRequiresTheFeatureFlag(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
installationID := installHookPlugin(t)
withPluginsV1Flag(t, testHandler, false)
recorder := httptest.NewRecorder()
testHandler.InvokePluginHook(recorder, invokeHookRequest(installationID, "summarize", map[string]any{"trigger": "manual"}))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s, want 503", recorder.Code, recorder.Body.String())
}
}
// A disabled installation is off, not hidden. A stale tab must not keep
// invoking hooks after an admin switches the plugin off.
func TestInvokePluginHookRefusesDisabledInstallation(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
installationID := installHookPlugin(t)
disable := httptest.NewRecorder()
testHandler.DisablePlugin(disable, pluginHandlerRequest(http.MethodPost, "/disable", nil, map[string]string{
"id": testWorkspaceID, "installationId": installationID,
}))
if disable.Code != http.StatusOK {
t.Fatalf("disable: status=%d body=%s", disable.Code, disable.Body.String())
}
recorder := httptest.NewRecorder()
testHandler.InvokePluginHook(recorder, invokeHookRequest(installationID, "summarize", map[string]any{"trigger": "manual"}))
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s, want 403", recorder.Code, recorder.Body.String())
}
}
// The install token is returned once and stored only as a hash, so the same
// token must never come back from a second rotation, and the old one must stop
// working the moment a new one is issued.
func TestRotatePluginTokenIssuesOnceAndInvalidatesThePrevious(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
installationID := installHookPlugin(t)
testHandler.PluginService.DeploymentKey = bytes.Repeat([]byte{9}, 32)
t.Cleanup(func() { testHandler.PluginService.DeploymentKey = nil })
first := rotateToken(t, installationID)
if first.Token == "" || first.SigningSecret == "" {
t.Fatalf("rotation returned nothing usable: %+v", first)
}
installation, err := testHandler.PluginService.AuthenticateInstallToken(context.Background(), first.Token)
if err != nil {
t.Fatalf("the freshly issued token must authenticate: %v", err)
}
if uuidToString(installation.ID) != installationID {
t.Fatalf("token resolved to the wrong installation: %s", uuidToString(installation.ID))
}
second := rotateToken(t, installationID)
if second.Token == first.Token {
t.Fatal("rotation reissued the same token")
}
if _, err := testHandler.PluginService.AuthenticateInstallToken(context.Background(), first.Token); err == nil {
t.Fatal("the previous token must stop working after a rotation")
}
// Derived from the deployment key and the installation, so it survives
// rotation — an author does not have to reconfigure their server every
// time an admin rotates the token.
if second.SigningSecret != first.SigningSecret {
t.Fatal("the signing secret must be stable across token rotations")
}
}
func TestRotatePluginTokenDoesNotRequireHookSigning(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
installationID := installHookPlugin(t)
previousKey := testHandler.PluginService.DeploymentKey
testHandler.PluginService.DeploymentKey = nil
t.Cleanup(func() { testHandler.PluginService.DeploymentKey = previousKey })
issued := rotateToken(t, installationID)
if issued.Token == "" {
t.Fatal("rotation did not return a Public API token")
}
if issued.SigningSecret != "" {
t.Fatalf("signing_secret = %q without a deployment key", issued.SigningSecret)
}
installation, err := testHandler.PluginService.AuthenticateInstallToken(context.Background(), issued.Token)
if err != nil {
t.Fatalf("token issued without hook signing must authenticate: %v", err)
}
if uuidToString(installation.ID) != installationID {
t.Fatalf("token resolved to %s, want %s", uuidToString(installation.ID), installationID)
}
}
func TestRotatePluginTokenPreservesPreviousTokenWhenPreparationFails(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
installationID := installHookPlugin(t)
previousKey := testHandler.PluginService.DeploymentKey
t.Cleanup(func() { testHandler.PluginService.DeploymentKey = previousKey })
testHandler.PluginService.DeploymentKey = bytes.Repeat([]byte{9}, 32)
previous := rotateToken(t, installationID)
testHandler.PluginService.DeploymentKey = []byte("invalid")
response := httptest.NewRecorder()
testHandler.RotatePluginToken(response, pluginHandlerRequest(http.MethodPost, "/token", nil, map[string]string{
"id": testWorkspaceID, "installationId": installationID,
}))
if response.Code != http.StatusBadGateway {
t.Fatalf("failed preparation status=%d body=%s", response.Code, response.Body.String())
}
if _, err := testHandler.PluginService.AuthenticateInstallToken(context.Background(), previous.Token); err != nil {
t.Fatalf("failed credential preparation invalidated the previous token: %v", err)
}
}
func TestRevokePluginTokenStopsAuthentication(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
installationID := installHookPlugin(t)
testHandler.PluginService.DeploymentKey = bytes.Repeat([]byte{9}, 32)
t.Cleanup(func() { testHandler.PluginService.DeploymentKey = nil })
issued := rotateToken(t, installationID)
recorder := httptest.NewRecorder()
testHandler.RevokePluginToken(recorder, pluginHandlerRequest(http.MethodDelete, "/token", nil, map[string]string{
"id": testWorkspaceID, "installationId": installationID,
}))
if recorder.Code != http.StatusNoContent {
t.Fatalf("revoke: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if _, err := testHandler.PluginService.AuthenticateInstallToken(context.Background(), issued.Token); err == nil {
t.Fatal("a revoked token must not authenticate")
}
}
// A garbage token must not resolve to some installation by accident — the
// lookup is by hash, so an empty or malformed value must be refused before it
// reaches the query.
func TestAuthenticateInstallTokenRefusesMalformedValues(t *testing.T) {
for _, token := range []string{"", "mpi_", "not-a-token", "mpc_something", "Bearer mpi_x"} {
if _, err := testHandler.PluginService.AuthenticateInstallToken(context.Background(), token); err == nil {
t.Fatalf("token %q must not authenticate", token)
}
}
}
func rotateToken(t *testing.T, installationID string) pluginTokenResponse {
t.Helper()
recorder := httptest.NewRecorder()
testHandler.RotatePluginToken(recorder, pluginHandlerRequest(http.MethodPost, "/token", nil, map[string]string{
"id": testWorkspaceID, "installationId": installationID,
}))
if recorder.Code != http.StatusOK {
t.Fatalf("rotate: status=%d body=%s", recorder.Code, recorder.Body.String())
}
var issued pluginTokenResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &issued); err != nil {
t.Fatalf("decode token response: %v", err)
}
return issued
}
// The feature flag gates the EVENT path, observed where it matters: does a
// request actually leave for the plugin's endpoint.
//
// Every other plugin surface is refused by a handler that can read the flag off
// a request. An event hook has none — the dispatcher is subscribed at startup
// and runs on a worker — so without a check inside it, turning plugins off
// would hide the UI and refuse the Action API while the outbound calls kept
// going. That is fail-open on the one path that reaches a third party.
//
// Needs a database because the dispatcher only calls anything when a real,
// enabled installation declares an event hook.
func TestEventDispatchRespectsTheFeatureFlagEndToEnd(t *testing.T) {
withPluginsV1Flag(t, testHandler, true)
cleanupPluginInstallations(t)
received := make(chan struct{}, 4)
endpoint := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
received <- struct{}{}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
t.Cleanup(endpoint.Close)
host := strings.Split(strings.TrimPrefix(endpoint.URL, "https://"), ":")[0]
manifest := strings.NewReplacer(
"https://example.com/hooks/summarize", endpoint.URL+"/hooks/summarize",
"https://example.com/hooks/manual", endpoint.URL+"/hooks/manual",
`"net:example.com"`, `"net:`+host+`"`,
).Replace(hookHandlerTestManifest)
versionID := withLocalPluginSource(t, manifest)
// The dev-origin opt-in is what lets a loopback endpoint be dialled at all;
// the granted net: scope still has to cover it, which the replace above did.
previousOrigins := testHandler.PluginService.DevOrigins
previousClient := testHandler.PluginService.HookClient
previousFlags := testHandler.PluginService.FeatureFlags
previousKey := testHandler.PluginService.DeploymentKey
previousCallbacks := testHandler.PluginService.Callbacks
testHandler.PluginService.DevOrigins = []string{endpoint.URL}
testHandler.PluginService.HookClient = endpoint.Client()
// Without a deployment key there is nothing to sign with and the call is
// refused before it leaves — which would make this test pass for the wrong
// reason in the flag-off half.
testHandler.PluginService.DeploymentKey = bytes.Repeat([]byte{5}, 32)
testHandler.PluginService.Callbacks = service.NewCallbackTokens()
t.Cleanup(func() {
testHandler.PluginService.DevOrigins = previousOrigins
testHandler.PluginService.HookClient = previousClient
testHandler.PluginService.FeatureFlags = previousFlags
testHandler.PluginService.DeploymentKey = previousKey
testHandler.PluginService.Callbacks = previousCallbacks
})
body, _ := json.Marshal(map[string]any{
"version_id": versionID,
"granted_scopes": []string{"issues:read", "comments:write", "net:" + host},
})
install := httptest.NewRecorder()
testHandler.InstallPlugin(install, pluginHandlerRequest(http.MethodPost, "/plugins", body, map[string]string{"id": testWorkspaceID}))
if install.Code != http.StatusCreated {
t.Fatalf("install: status=%d body=%s", install.Code, install.Body.String())
}
dispatch := func() *service.PluginEventDispatcher {
dispatcher := service.NewPluginEventDispatcher(testHandler.PluginService)
dispatcher.Dispatch(plugincontract.EventIssueCreated, testWorkspaceID, map[string]any{})
return dispatcher
}
// Flag on: the endpoint is called.
testHandler.PluginService.FeatureFlags = testHandler.FeatureFlags
dispatcher := dispatch()
select {
case <-received:
case <-time.After(2 * time.Second):
dispatcher.Close()
t.Fatal("with the flag on, an installed event hook was never called")
}
dispatcher.Close()
// Flag off: nothing leaves, even though the same installation is still
// enabled and still declares the hook.
withPluginsV1Flag(t, testHandler, false)
looked := make(chan struct{}, 1)
testHandler.PluginService.FeatureFlags = featureflag.NewService(flagLookupSignal{
Provider: testHandler.FeatureFlags.Provider(), key: featureflags.PluginsV1, looked: looked,
})
dispatcher = dispatch()
defer dispatcher.Close()
select {
case <-looked:
case <-time.After(2 * time.Second):
t.Fatal("with the flag off, no dispatcher worker ever evaluated the flag")
}
// Close waits for the worker that read the flag to finish its job, so any
// call it made has already reached the endpoint. No window to wait out.
dispatcher.Close()
select {
case <-received:
t.Fatal("with the flag off, an event hook still called out — the flag does not gate the outbound path")
default:
}
}
// flagLookupSignal reports each evaluation of one flag, so a test can tell a
// worker has reached its flag check instead of waiting out a window for a call
// that should never come.
type flagLookupSignal struct {
featureflag.Provider
key string
looked chan<- struct{}
}
func (p flagLookupSignal) Lookup(ctx context.Context, key string) (featureflag.Decision, bool) {
decision, found := p.Provider.Lookup(ctx, key)
if key == p.key {
select {
case p.looked <- struct{}{}:
default:
}
}
return decision, found
}