Files
multica/server/internal/handler/autopilot_trigger_error_test.go
T
a12985ab01 MUL-6472: fix(autopilots): stop leaking dispatch error text through Run now (#7311)
* MUL-6472: fix(server): stop echoing dispatch errors from TriggerAutopilot

The unclassified-failure branch appended err.Error() to its 500 body, so a
member clicking "run now" could read the Go error chain behind it — pgx table
and constraint names, SQLSTATE, internal ids. It was the only 5xx in this file
that did; the other err.Error() sites are 400s written for the user.

Return the fixed string and log the chain server-side instead, matching how
autopilot_webhook.go reports its own internal failures. The quota branch above
keeps its structured 429 + Retry-After body untouched.

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

* MUL-6472: fix(views): keep server 5xx text out of the run-now toast

The catch branch fell back to err.message, which is the server's error string —
so the fixed 500 above would still have shown as English "failed to trigger
autopilot" in a Chinese UI, and any other 5xx would show its raw body.

clientErrorMessage() keeps a 4xx message (handlers write those for the user:
"autopilot is not active") and withholds a 5xx one or a transport failure, so
run-now degrades to its localized generic sentence. Typed reason_code outcomes
are unaffected — they were already localized.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 16:48:03 +08:00

86 lines
3.5 KiB
Go

package handler
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/multica-ai/multica/server/internal/entitlement"
"github.com/multica-ai/multica/server/internal/entitlement/entitlementtest"
"github.com/multica-ai/multica/server/internal/testutil"
)
// TestTriggerAutopilot_InternalFailureDoesNotEchoError pins MUL-6472: an
// unclassified dispatch failure answers with a FIXED 500 string. The chain
// behind it names internal machinery (here "create run: load idempotent quota
// run: no rows in result set", elsewhere pgx table/constraint names), and every
// workspace member can reach "run now" — so none of it may reach the body. The
// 429 quota branch above it is deliberately structured and stays covered by
// TestAutopilotQuotaManualAndWebhookEnforcement.
func TestTriggerAutopilot_InternalFailureDoesNotEchoError(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
// A valid, far-from-exhausted policy: the request has to travel past the
// 429 branch and into the idempotency lookup this test breaks.
start := time.Now().UTC().Truncate(time.Second).Add(-time.Hour)
end := start.Add(24 * time.Hour)
limit := 1000
stub := entitlementtest.New()
stub.Set(uuid.MustParse(testWorkspaceID), entitlement.GateAutopilotRuns, entitlement.Decision{
Gate: entitlement.Gate{
Action: entitlement.ActionEnforce, Limit: &limit,
PeriodStart: &start, PeriodEnd: &end, ResetAt: &end,
},
PolicyRevision: 3, SubscriptionVersion: 5,
})
priorProvider := testHandler.AutopilotService.Entitlements
testHandler.AutopilotService.Entitlements = stub
t.Cleanup(func() {
testHandler.AutopilotService.Entitlements = priorProvider
testPool.Exec(context.Background(), `DELETE FROM autopilot_quota_period WHERE workspace_id = $1`, testWorkspaceID)
})
agentID := createWebhookTestAgent(t, "MUL-6472 Trigger Agent")
autopilotID := createWebhookTestAutopilot(t, agentID, "active", "run_only")
// A settled reservation with no run row pointing back at it. Reusing its
// key sends dispatch down the idempotent-reuse path, where the missing run
// is an internal error rather than the recoverable "reserved" orphan case.
// The stored key is the one the manual entry point composes, not the raw
// header value.
const idempotencyKey = "mul-6472-orphaned-reservation"
dbfx.Insert(t, "autopilot_quota_reservation", testutil.Cols{
"workspace_id": testWorkspaceID,
"period_start": start,
"period_end": end,
"policy_revision": 3,
"subscription_version": 5,
"source": "manual",
"idempotency_key": "manual:" + autopilotID + ":" + idempotencyKey,
"state": "consumed",
})
req := testutil.WithHeaders(
newRequest("POST", "/api/autopilots/"+autopilotID+"/trigger", nil),
"Idempotency-Key", idempotencyKey,
)
res := testutil.Call(t, testHandler.TriggerAutopilot, withURLParam(req, "id", autopilotID)).
Want(http.StatusInternalServerError)
if got := res.Map()["error"]; got != "failed to trigger autopilot" {
t.Fatalf("error = %#v, want the fixed string with no error chain appended", got)
}
// Substrings of the chain this failure actually produces, plus the pgx
// marker every DB-level failure on this path would carry.
for _, leak := range []string{"create run", "idempotent", "quota", "no rows", "sqlstate"} {
if strings.Contains(strings.ToLower(res.Text()), leak) {
t.Fatalf("500 body leaks internal detail %q: %s", leak, res.Text())
}
}
}