Files
multica/server/internal/handler/vcs_test.go
Bohan Jiang 73b0015475 feat(vcs): make self-hosted Git providers self-host-only (MUL-3772, MUL-5138) (#5888)
* feat(vcs): gate self-hosted Git providers to self-host deployments only (MUL-3772)

The Forgejo/Gitea/GitLab integration is intended for self-hosted Multica, where
Multica can reach a Git instance on the operator's own network. On the managed
multi-tenant cloud it adds an SSRF surface (connect validates a user-supplied
instance URL from the server) and would store third-party Git tokens for all
tenants under one key, while only serving the small subset of users whose
instance is publicly reachable. Product decision: offer it on self-host only.

- Add an explicit deployment switch MULTICA_VCS_INTEGRATION_ENABLED (default
  off). Connect, rotate, and webhook now require BOTH the switch on AND a valid
  MULTICA_VCS_SECRET_KEY — the switch is the product boundary, not key presence
  alone. When off, connect/rotate return 404 and the webhook returns a bare 404
  (no config leak), independent of the frontend.
- /api/config exposes vcs_integration_available (mirrors the switch, omitted
  when false) so the Settings UI hides the whole "Git providers" section on
  cloud instead of surfacing an operator-only "missing key" hint.
- docker-compose.selfhost.yml defaults the switch on; .env.example documents it.
- Docs (en/zh) lead with a callout: available on self-hosted Multica only, not
  Multica Cloud, and clarify "self-hosted" means Multica itself, not just Git.

#5006 / #5883 stay in place — the schema and backend capability are retained;
this only gates availability. No cloud VCS connection can exist (connect always
required the key, which the cloud never set), so nothing needs migrating.

Verified: go build/vet + VCS/config handler tests on a fresh migrated DB
(incl. a new disabled-deployment 404 test); pnpm typecheck (core + views) and
the integrations-tab + core schema/config vitest suites pass.

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

* fix(vcs): complete self-host integration gating (MUL-5138)

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 16:39:22 +08:00

176 lines
5.8 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/go-chi/chi/v5"
)
func vcsHandlerRequest(method, path string, body any, connectionID string) *http.Request {
req := newRequest(method, path, body)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", testWorkspaceID)
if connectionID != "" {
rctx.URLParams.Add("connectionId", connectionID)
}
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
func TestListVCSConnectionsHonorsDeploymentSwitch(t *testing.T) {
ctx := context.Background()
box := withVCSBox(t)
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo-list.test")
t.Cleanup(func() { cleanupVCS(context.Background(), "") })
fetch := func() struct {
Connections []VCSConnectionResponse `json:"connections"`
Available bool `json:"available"`
Configured bool `json:"configured"`
} {
t.Helper()
req := vcsHandlerRequest(http.MethodGet, "/api/workspaces/"+testWorkspaceID+"/vcs/connections", nil, "")
w := httptest.NewRecorder()
testHandler.ListVCSConnections(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListVCSConnections: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Connections []VCSConnectionResponse `json:"connections"`
Available bool `json:"available"`
Configured bool `json:"configured"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode ListVCSConnections: %v", err)
}
return resp
}
testHandler.cfg.VCSIntegrationEnabled = false
disabled := fetch()
if disabled.Available || disabled.Configured || len(disabled.Connections) != 0 {
t.Fatalf("disabled response must hide stored connections and availability, got %+v", disabled)
}
testHandler.cfg.VCSIntegrationEnabled = true
enabled := fetch()
if !enabled.Available || !enabled.Configured {
t.Fatalf("enabled response must expose availability and configuration, got %+v", enabled)
}
if len(enabled.Connections) != 1 || enabled.Connections[0].ID != connID {
t.Fatalf("enabled response must include seeded connection %s, got %+v", connID, enabled.Connections)
}
}
func TestConnectVCSHonorsDeploymentSwitch(t *testing.T) {
var validationCalls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
validationCalls.Add(1)
if r.URL.Path != "/api/v1/user" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"vcs-test-user"}`))
}))
defer provider.Close()
withVCSBox(t)
t.Cleanup(func() { cleanupVCS(context.Background(), "") })
body := map[string]any{
"provider": "forgejo",
"instance_url": provider.URL,
"access_token": "test-token",
}
connect := func() *httptest.ResponseRecorder {
t.Helper()
req := vcsHandlerRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/vcs/connections", body, "")
w := httptest.NewRecorder()
testHandler.ConnectVCS(w, req)
return w
}
countConnections := func() int {
t.Helper()
var count int
if err := testPool.QueryRow(context.Background(),
`SELECT count(*) FROM vcs_connection WHERE workspace_id = $1 AND instance_url = $2`,
testWorkspaceID, provider.URL,
).Scan(&count); err != nil {
t.Fatalf("count VCS connections: %v", err)
}
return count
}
testHandler.cfg.VCSIntegrationEnabled = false
if w := connect(); w.Code != http.StatusNotFound {
t.Fatalf("disabled ConnectVCS: expected 404, got %d: %s", w.Code, w.Body.String())
}
if got := validationCalls.Load(); got != 0 {
t.Fatalf("disabled ConnectVCS must not call provider, got %d requests", got)
}
if got := countConnections(); got != 0 {
t.Fatalf("disabled ConnectVCS must not write a connection, got %d rows", got)
}
testHandler.cfg.VCSIntegrationEnabled = true
if w := connect(); w.Code != http.StatusOK {
t.Fatalf("enabled ConnectVCS: expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := validationCalls.Load(); got != 1 {
t.Fatalf("enabled ConnectVCS: expected one provider validation, got %d", got)
}
if got := countConnections(); got != 1 {
t.Fatalf("enabled ConnectVCS: expected one stored connection, got %d", got)
}
}
func TestRotateVCSConnectionWebhookHonorsDeploymentSwitch(t *testing.T) {
ctx := context.Background()
box := withVCSBox(t)
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo-rotate.test")
t.Cleanup(func() { cleanupVCS(context.Background(), "") })
connUUID := parseUUID(connID)
loadSecret := func() string {
t.Helper()
conn, err := testHandler.Queries.GetVCSConnectionByID(context.Background(), connUUID)
if err != nil {
t.Fatalf("GetVCSConnectionByID: %v", err)
}
return conn.WebhookSecretEncrypted
}
rotate := func() *httptest.ResponseRecorder {
t.Helper()
req := vcsHandlerRequest(
http.MethodPost,
"/api/workspaces/"+testWorkspaceID+"/vcs/connections/"+connID+"/rotate-webhook",
nil,
connID,
)
w := httptest.NewRecorder()
testHandler.RotateVCSConnectionWebhook(w, req)
return w
}
originalSecret := loadSecret()
testHandler.cfg.VCSIntegrationEnabled = false
if w := rotate(); w.Code != http.StatusNotFound {
t.Fatalf("disabled RotateVCSConnectionWebhook: expected 404, got %d: %s", w.Code, w.Body.String())
}
if got := loadSecret(); got != originalSecret {
t.Fatal("disabled RotateVCSConnectionWebhook must not modify the stored secret")
}
testHandler.cfg.VCSIntegrationEnabled = true
if w := rotate(); w.Code != http.StatusOK {
t.Fatalf("enabled RotateVCSConnectionWebhook: expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := loadSecret(); got == originalSecret {
t.Fatal("enabled RotateVCSConnectionWebhook must replace the stored secret")
}
}