Files
multica/server/internal/handler/wecom_install_error_matrix_test.go
0db0055964 fix(wecom): stop blaming an admin's credentials when the fault is ours (#6589)
* fix(wecom): stop blaming an admin's credentials when the fault is ours

RegisterWecomBYO's default branch answered 400 with "check the Bot ID and
secret from the WeCom admin console". Every failure that is not one of the
three ownership conflicts lands there — a Postgres blip, a bad encryption key,
a bug of ours — and all of them tell the admin their credentials are wrong.

That is not a cosmetic mistake. It sends them to the WeCom console to rotate a
secret that was fine, and a rotated WeCom long-connection secret cannot be
recovered: the console shows it once. A thirty-second database failover costs
them a working bot.

Split by whose fault it is. validateInstallationParams now wraps
ErrInvalidInstallationParams, so a missing field is identifiable as the
caller's to fix and keeps its 400. Everything else falls to a 500 that says so
plainly and states that the credentials were not changed, logged at Error with
the workspace and agent rather than Warn.

Each branch also carries a stable code next to the English sentence, through a
new writeErrorCode helper. The settings tab reads it (errorCode, alongside the
existing dispatchReasonCode) and renders its own localized string, so a
zh-Hans admin stops being toasted English. The sentence remains the fallback
for a server that sends no code.

Rebased onto main, which brought #6594's credential probe into the same
switch. The two are complementary and both are kept whole: #6594 decides
whether WeCom refused the pair (ErrCredentialsRejected) or could not be
reached at all (ErrCredentialsUnverifiable); this change decides what the
admin is told about each. Both now carry their own stable code —
wecom_credentials_rejected (400) and wecom_credentials_unverifiable (503) —
rather than folding into wecom_install_rejected, which means "you left a field
out" and must keep meaning only that: the three call for three different
actions from the admin.

The full matrix is now one named function, writeWecomInstallError, instead of
a switch inline in the handler:

  bot already owned (same ws / archived agent / other ws) → 409
  required field missing                                  → 400
  WeCom refused the credentials                           → 400
  WeCom unreachable, credentials unverified               → 503
  anything else (DB, encryption key, a bug)               → 500

Seven new keys in en/ja/ko/zh-Hans, key sets verified identical across all
four at 45 keys each.

Tests. At the handler level, a table-driven test pins HTTP status + error +
code for all seven outcomes, plus a wrapped sentinel and a second test that
every outcome's code is distinct — the layer where a bad merge lands and where
nothing was asserting before, since the wecom-package test only proves
sentinel matching and the frontend test only mocks the code. Dropping either
credential arm fails the matrix test; giving two outcomes one code fails the
distinctness test even though both are 400 and the status assertions still
pass. In the wecom package, every missing field is identifiable as a caller
error and names itself, complete params pass, and an infrastructure error does
NOT match the sentinel. Four frontend tests cover the localized render, both
new codes, and the no-code fallback.

* test(wecom): pin each install outcome's own fallback sentence

The matrix test's header said it pinned status + error + code, but the
table carried no expected sentence and the assertion was only that one
was non-empty. That leaves the case it was written to catch: a resolution
that keeps every status and code correct and hands one outcome its
neighbour's sentence still passes, and a client too old to know the codes
then shows the admin the wrong next action — "check your credentials" for
an outage, or "wait and retry" for a pair WeCom actually refused.

The sentence is a contract with those clients as much as the status is,
so the table now carries it and the test compares it exactly. Verified by
crossing the rejected/unverifiable sentences in writeWecomInstallError:
before, green; now, three rows fail with the status and code still right.

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

* docs(wecom): un-orphan the validateInstallationParams comment, drop the stale 500 claim

Two problems in the same block. ErrInvalidInstallationParams was declared
between validateInstallationParams's doc comment and the function, so
godoc read "validateInstallationParams is a lightweight pre-write check"
as documentation of the error value and left the function undocumented.

And the text said everything that is not one of these belongs on a 500,
which stopped being true when #6594 added ErrCredentialsRejected (400) and
ErrCredentialsUnverifiable (503). Left as-is it invites the next reader to
collapse exactly the distinctions this change spent its effort drawing.

Comment split back onto what each one documents, and the classification
now names all three outcomes and points at writeWecomInstallError, which
holds the matrix. No behaviour change.

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

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-08 19:20:38 +08:00

200 lines
8.5 KiB
Go

package handler
// wecom_install_error_matrix_test.go — the install response matrix is a
// contract with two clients at once: an HTTP status the API caller branches on,
// and a stable `code` the settings tab translates. Neither is visible to the
// tests one layer down. installation_params_test.go proves the sentinel
// matching inside the wecom package, and the frontend test mocks the code it
// expects to receive — so between them sits the wiring, where the sentinel is
// turned into a status and a code, and nothing was asserting that.
//
// That gap is exactly where a merge goes wrong: this switch has now taken new
// arms from two directions (the credential probe's rejected/unverifiable pair,
// and this branch's stable codes), and a resolution that drops an arm or gives
// two outcomes the same code still compiles and still passes every other test
// in the tree. The table below is the matrix itself, so a dropped arm fails
// here instead of reaching an admin as the wrong next action.
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/wecom"
)
// TestWecomInstallErrorMatrix pins status + error + code for every error class
// the install path can produce.
func TestWecomInstallErrorMatrix(t *testing.T) {
wsUUID := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
agentUUID := pgtype.UUID{Bytes: [16]byte{2}, Valid: true}
cases := []struct {
name string
err error
wantStatus int
wantCode string
// wantError is the exact sentence, not a smoke check that one exists.
// It is what a client too old to know the codes shows the admin, so it
// is as much a part of the contract as the status: an outcome that
// keeps its status and code but picks up a neighbour's sentence still
// sends those clients to do the wrong thing, and only pinning the text
// catches that.
wantError string
// why records the admin action this outcome is telling them to take.
// Two rows that share a code cannot be telling them different things.
why string
}{
{
name: "bot owned by another agent in this workspace",
err: wecom.ErrBotOwnedBySameWorkspace,
wantStatus: http.StatusConflict,
wantCode: "wecom_bot_owned_by_same_workspace",
wantError: "this bot is already connected to another agent in this workspace — disconnect it there first, then connect it here",
why: "go disconnect it from the other agent here",
},
{
name: "bot owned by an archived agent",
err: wecom.ErrBotOwnedByArchivedAgent,
wantStatus: http.StatusConflict,
wantCode: "wecom_bot_owned_by_archived_agent",
wantError: "this bot is connected to an archived agent in this workspace — restore that agent, or disconnect its bot, before connecting it here",
why: "restore that agent or disconnect its bot",
},
{
name: "bot owned by another workspace",
err: wecom.ErrBotOwnedByAnotherWorkspace,
wantStatus: http.StatusConflict,
wantCode: "wecom_bot_owned_by_another_workspace",
wantError: "this bot is already connected to a different Multica workspace — disconnect it there before connecting it here",
why: "go disconnect it in the other workspace",
},
{
name: "required field missing",
err: fmt.Errorf("%w: bot_id is required", wecom.ErrInvalidInstallationParams),
wantStatus: http.StatusBadRequest,
wantCode: "wecom_install_rejected",
wantError: "could not connect the WeCom bot — check the Bot ID and secret from the WeCom admin console, and that the bot is a smart bot with the long connection enabled",
why: "fill in the field you left out",
},
{
name: "WeCom refused the credentials",
err: wecom.ErrCredentialsRejected,
wantStatus: http.StatusBadRequest,
wantCode: "wecom_credentials_rejected",
wantError: "WeCom rejected this Bot ID and secret — check both on the WeCom admin console, and that the bot is a smart bot with the long connection enabled",
why: "go check the pair on the WeCom console — the only outcome that may say this",
},
{
name: "WeCom unreachable, credentials unverified",
err: wecom.ErrCredentialsUnverifiable,
wantStatus: http.StatusServiceUnavailable,
wantCode: "wecom_credentials_unverifiable",
wantError: "could not reach WeCom to verify this bot — the credentials were not changed; try again in a moment",
why: "wait and retry; the credentials were not changed",
},
{
name: "our database is down",
err: errors.New("dial tcp 10.0.0.5:5432: connect: connection refused"),
wantStatus: http.StatusInternalServerError,
wantCode: "wecom_install_failed",
wantError: "could not save this bot — something went wrong on our side. Your credentials were not changed; please try again, and contact support if it keeps failing",
why: "nothing — it is ours to fix, and the admin must not be sent to rotate a secret",
},
{
name: "the encryption key is missing",
err: errors.New("wecom: encrypt secret: secretbox: no key"),
wantStatus: http.StatusInternalServerError,
wantCode: "wecom_install_failed",
wantError: "could not save this bot — something went wrong on our side. Your credentials were not changed; please try again, and contact support if it keeps failing",
why: "nothing — ours",
},
{
name: "a wrapped sentinel is still classified by its cause",
err: fmt.Errorf("upsert: %w", wecom.ErrCredentialsUnverifiable),
wantStatus: http.StatusServiceUnavailable,
wantCode: "wecom_credentials_unverifiable",
wantError: "could not reach WeCom to verify this bot — the credentials were not changed; try again in a moment",
why: "errors.Is, not string matching",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
writeWecomInstallError(w, tc.err, wsUUID, agentUUID)
if w.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (%s)", w.Code, tc.wantStatus, tc.why)
}
var body struct {
Error string `json:"error"`
Code string `json:"code"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("response body is not JSON: %v (body: %s)", err, w.Body.String())
}
if body.Code != tc.wantCode {
t.Errorf("code = %q, want %q (%s)", body.Code, tc.wantCode, tc.why)
}
// The sentence is what a client that does not know the code shows,
// so it has to be this outcome's own sentence — an empty one leaves
// those clients a bare status, and a neighbour's tells the admin to
// take the wrong action.
if body.Error != tc.wantError {
t.Errorf("error = %q, want %q (%s)", body.Error, tc.wantError, tc.why)
}
})
}
}
// TestWecomInstallCodesAreDistinct: the codes are the contract the frontend
// switches on, so two outcomes sharing one code means the tab cannot tell them
// apart and shows the wrong next action for one of them. In particular
// wecom_install_rejected means "you left a field out" and must not widen to
// cover "WeCom said no" or "we could not ask WeCom" — those are different
// actions, and #6594 made them separately detectable for that reason.
func TestWecomInstallCodesAreDistinct(t *testing.T) {
wsUUID := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
agentUUID := pgtype.UUID{Bytes: [16]byte{2}, Valid: true}
errs := map[string]error{
"same workspace": wecom.ErrBotOwnedBySameWorkspace,
"archived agent": wecom.ErrBotOwnedByArchivedAgent,
"other workspace": wecom.ErrBotOwnedByAnotherWorkspace,
"invalid params": wecom.ErrInvalidInstallationParams,
"creds rejected": wecom.ErrCredentialsRejected,
"creds unverified": wecom.ErrCredentialsUnverifiable,
"ours": errors.New("something we broke"),
}
seen := make(map[string]string, len(errs))
for name, err := range errs {
w := httptest.NewRecorder()
writeWecomInstallError(w, err, wsUUID, agentUUID)
var body struct {
Code string `json:"code"`
}
if uerr := json.Unmarshal(w.Body.Bytes(), &body); uerr != nil {
t.Fatalf("%s: response body is not JSON: %v", name, uerr)
}
if body.Code == "" {
t.Errorf("%s: no code — the settings tab falls back to the English sentence", name)
continue
}
if prev, dup := seen[body.Code]; dup {
t.Errorf("%q is returned for both %q and %q; the frontend cannot tell them apart, so one of the two gets the wrong next action",
body.Code, prev, name)
continue
}
seen[body.Code] = name
}
}