mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* 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]>
86 lines
3.5 KiB
Go
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())
|
|
}
|
|
}
|
|
}
|