Files
multica/server/pkg/redact/redact_test.go
LinYushen b933d9fd41 feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)

Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).

- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
  active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
  CompleteCallback (idempotent upsert), ListConnections, Disconnect
  (upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
  pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
  COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
  delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
  toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
  or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
  or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
  idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
  handlers (httptest), redact regression for Bearer mcp_ tokens

MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).

Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)

Address PR 4608 review (CHANGES_REQUESTED):

- callback: verify connected_account_id with Composio before mirroring it.
  The signed state only proved user/toolkit/exp, so a valid state paired with
  a tampered connected_account_id would be written verbatim. CompleteCallback
  now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
  unless the account belongs to the state's user (composio_user_id == multica
  user id) and was created under the toolkit's auth config. No row is written
  on mismatch / unknown account / upstream error.

- disconnect: short-circuit to a no-op when the local row is already revoked,
  before touching upstream. Previously a second DELETE re-hit Composio and a
  non-404 upstream error surfaced as a 502, breaking the 204-idempotent
  contract.

- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
  constraint and make duplicate selection deterministic (newest-wins, rows are
  connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
  real single-account-enforcement vs multi-account-shape decision.

Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.

Co-authored-by: multica-agent <github@multica.ai>

* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)

Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.

Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
  is now resolved at request time from the project's /auth_configs (cached,
  5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
  to exactly one project; the project is resolved from the key. Only org-level
  endpoints use x-org-api-key, which this integration never calls.

Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
  is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
  custom/white-label config over Composio-managed, newest wins); BeginConnect
  and CompleteCallback resolve via it; ListToolkits fetches the full catalog
  (paginated, capped) annotated with connectable = has an enabled auth config,
  connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
  when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.

Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
  composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
  toolkit as a card with search. Connect is gated on `connectable`;
  non-connectable toolkits show a muted "not configured" hint instead of a
  dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.

Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.

Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)

Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.

Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.

Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.

Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.

Co-authored-by: multica-agent <github@multica.ai>

* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)

Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.

Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-29 12:50:11 +08:00

233 lines
6.4 KiB
Go

package redact
import (
"strings"
"testing"
)
func TestRedactAWSAccessKey(t *testing.T) {
t.Parallel()
input := "Found key AKIAIOSFODNN7EXAMPLE in config"
got := Text(input)
if strings.Contains(got, "AKIAIOSFODNN7EXAMPLE") {
t.Fatalf("AWS key not redacted: %s", got)
}
if !strings.Contains(got, "[REDACTED AWS KEY]") {
t.Fatalf("expected [REDACTED AWS KEY] placeholder, got: %s", got)
}
}
func TestRedactAWSSecretKey(t *testing.T) {
t.Parallel()
input := "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
got := Text(input)
if strings.Contains(got, "wJalrXUtnFEMI") {
t.Fatalf("AWS secret not redacted: %s", got)
}
}
func TestRedactPrivateKey(t *testing.T) {
t.Parallel()
input := "Here is the key:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----\nDone."
got := Text(input)
if strings.Contains(got, "MIIEow") {
t.Fatalf("private key content not redacted: %s", got)
}
if !strings.Contains(got, "[REDACTED PRIVATE KEY]") {
t.Fatalf("expected [REDACTED PRIVATE KEY] placeholder, got: %s", got)
}
}
func TestRedactGitHubToken(t *testing.T) {
t.Parallel()
input := "export GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"
got := Text(input)
if strings.Contains(got, "ghp_") {
t.Fatalf("GitHub token not redacted: %s", got)
}
}
func TestRedactOpenAIKey(t *testing.T) {
t.Parallel()
input := "OPENAI_API_KEY=sk-proj-abc123def456ghi789jkl012mno345"
got := Text(input)
if strings.Contains(got, "sk-proj-abc123") {
t.Fatalf("OpenAI key not redacted: %s", got)
}
}
func TestRedactSlackToken(t *testing.T) {
t.Parallel()
input := "token: xoxb-123456789012-1234567890123-AbCdEfGhIjKl"
got := Text(input)
if strings.Contains(got, "xoxb-") {
t.Fatalf("Slack token not redacted: %s", got)
}
}
func TestRedactBearerToken(t *testing.T) {
t.Parallel()
input := "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123"
got := Text(input)
if strings.Contains(got, "eyJhbGci") {
t.Fatalf("Bearer token not redacted: %s", got)
}
}
// TestRedactBearerMCPToken is a regression guard for the Composio MCP session
// headers (MUL-3720): the SDK attaches the project key as `Bearer mcp_...` on
// some MCP transports, so the generic Bearer pattern must mask it before it can
// reach a log line or WS broadcast.
func TestRedactBearerMCPToken(t *testing.T) {
t.Parallel()
input := "connecting with Authorization: Bearer mcp_AbCdEf0123456789-_token"
got := Text(input)
if strings.Contains(got, "mcp_AbCdEf0123456789") {
t.Fatalf("Bearer mcp_ token not redacted: %s", got)
}
if !strings.Contains(got, "Bearer [REDACTED]") {
t.Fatalf("expected Bearer [REDACTED] placeholder, got: %s", got)
}
}
func TestRedactGenericCredentials(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
}{
{"API_KEY", "API_KEY=mysupersecretkey123"},
{"DATABASE_URL", "DATABASE_URL=postgres://user:pass@host/db"},
{"DB_PASSWORD", "DB_PASSWORD: hunter2"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Text(tc.input)
if !strings.Contains(got, "[REDACTED CREDENTIAL]") {
t.Fatalf("expected credential redaction for %s, got: %s", tc.name, got)
}
})
}
}
func TestRedactHomeDirectory(t *testing.T) {
t.Parallel()
if homeDir == "" || username == "" {
t.Skip("cannot determine home dir or username")
}
input := "Reading file at " + homeDir + "/Documents/secret.txt"
got := Text(input)
if strings.Contains(got, username) {
t.Fatalf("home directory username not redacted: %s", got)
}
if !strings.Contains(got, "****") {
t.Fatalf("expected **** in path, got: %s", got)
}
}
func TestNoFalsePositivesOnNormalText(t *testing.T) {
t.Parallel()
inputs := []string{
"This is a normal commit message about fixing a bug",
"The function returns skip-navigation as the class name",
"Created PR #42 for the authentication feature",
"Running tests in /tmp/test-workspace/project",
"The API endpoint /api/issues/123 was updated",
}
for _, input := range inputs {
got := Text(input)
if got != input {
t.Fatalf("false positive redaction:\n input: %s\n output: %s", input, got)
}
}
}
func TestRedactGitLabToken(t *testing.T) {
t.Parallel()
input := "GITLAB_TOKEN=glpat-AbCdEfGhIjKlMnOpQrStUvWx"
got := Text(input)
if strings.Contains(got, "glpat-") {
t.Fatalf("GitLab token not redacted: %s", got)
}
}
func TestRedactJWT(t *testing.T) {
t.Parallel()
input := "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
got := Text(input)
if strings.Contains(got, "eyJhbGci") {
t.Fatalf("JWT not redacted: %s", got)
}
}
func TestRedactConnectionString(t *testing.T) {
t.Parallel()
input := "connecting to postgres://admin:s3cret@db.example.com:5432/mydb"
got := Text(input)
if strings.Contains(got, "s3cret") {
t.Fatalf("connection string password not redacted: %s", got)
}
}
func TestRedactPasswordEnvVar(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
}{
{"PASSWORD", "PASSWORD=hunter2"},
{"SECRET", "SECRET=mysecretvalue"},
{"TOKEN", "TOKEN=abc123xyz"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Text(tc.input)
if !strings.Contains(got, "[REDACTED CREDENTIAL]") {
t.Fatalf("expected credential redaction for %s, got: %s", tc.name, got)
}
})
}
}
func TestInputMap(t *testing.T) {
t.Parallel()
m := map[string]any{
"command": "echo sk-proj-abc123def456ghi789jkl012mno345",
"file_path": "/tmp/test.txt",
"count": 42,
}
got := InputMap(m)
if s, ok := got["command"].(string); ok {
if strings.Contains(s, "sk-proj") {
t.Fatalf("API key in input map not redacted: %s", s)
}
}
// Non-string values preserved
if got["count"] != 42 {
t.Fatalf("non-string value altered: %v", got["count"])
}
// Clean strings unchanged
if got["file_path"] != "/tmp/test.txt" {
t.Fatalf("clean string altered: %v", got["file_path"])
}
}
func TestInputMapNil(t *testing.T) {
t.Parallel()
if got := InputMap(nil); got != nil {
t.Fatalf("expected nil, got: %v", got)
}
}
func TestRedactMultipleSecrets(t *testing.T) {
t.Parallel()
input := "Keys: AKIAIOSFODNN7EXAMPLE and ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"
got := Text(input)
if strings.Contains(got, "AKIAIOSFODNN7EXAMPLE") {
t.Fatal("AWS key not redacted in multi-secret text")
}
if strings.Contains(got, "ghp_") {
t.Fatal("GitHub token not redacted in multi-secret text")
}
}