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

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan-J
2026-07-24 16:29:07 +08:00
parent f294bfb0eb
commit ea069e6382
6 changed files with 254 additions and 0 deletions

View File

@@ -163,6 +163,12 @@ jobs:
go-version: "1.26.1"
cache-dependency-path: server/go.sum
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Test Helm chart
run: bash scripts/helm-config.test.sh
- name: Build
run: cd server && go build ./...

View File

@@ -17,6 +17,7 @@ data:
ALLOWED_EMAILS: {{ .Values.backend.config.allowedEmails | quote }}
ALLOWED_EMAIL_DOMAINS: {{ .Values.backend.config.allowedEmailDomains | quote }}
DISABLE_WORKSPACE_CREATION: {{ .Values.backend.config.disableWorkspaceCreation | quote }}
MULTICA_VCS_INTEGRATION_ENABLED: {{ .Values.backend.config.vcsIntegrationEnabled | quote }}
GOOGLE_CLIENT_ID: {{ .Values.backend.config.googleClientId | quote }}
GOOGLE_REDIRECT_URI: {{ .Values.backend.config.googleRedirectUri | quote }}
S3_BUCKET: {{ .Values.backend.config.s3Bucket | quote }}

View File

@@ -124,6 +124,11 @@ backend:
# every caller. Bootstrap the workspace with this false, then flip to
# true so users can only join via invitation.
disableWorkspaceCreation: false
# Self-host-only VCS integration (Forgejo / Gitea / GitLab). The official
# chart enables the product surface by default; operators must also provide
# MULTICA_VCS_SECRET_KEY through existingSecret before connections work.
# Set false to hide and reject the integration for this deployment.
vcsIntegrationEnabled: true
googleClientId: ""
googleRedirectUri: http://multica.dev.lan/auth/callback
s3Bucket: ""

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CHART_DIR="$ROOT_DIR/deploy/helm/multica"
require_rendered_value() {
local rendered=$1
local expected=$2
if ! grep -Fq "$expected" <<<"$rendered"; then
echo "Missing expected Helm-rendered config value:"
echo " $expected"
exit 1
fi
}
helm lint "$CHART_DIR"
default_config="$(
helm template multica "$CHART_DIR" \
--show-only templates/configmap.yaml
)"
require_rendered_value "$default_config" 'MULTICA_VCS_INTEGRATION_ENABLED: "true"'
disabled_config="$(
helm template multica "$CHART_DIR" \
--show-only templates/configmap.yaml \
--set backend.config.vcsIntegrationEnabled=false
)"
require_rendered_value "$disabled_config" 'MULTICA_VCS_INTEGRATION_ENABLED: "false"'
echo "helm config rendering ok"

View File

@@ -104,6 +104,40 @@ func TestGetConfigIncludesRuntimeAuthConfig(t *testing.T) {
}
}
func TestGetConfigHonorsVCSIntegrationSwitch(t *testing.T) {
origCfg := testHandler.cfg
t.Cleanup(func() { testHandler.cfg = origCfg })
fetch := func() map[string]json.RawMessage {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
w := httptest.NewRecorder()
testHandler.GetConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
}
var cfg map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
testHandler.cfg.VCSIntegrationEnabled = false
if _, ok := fetch()["vcs_integration_available"]; ok {
t.Fatal("vcs_integration_available must be omitted when the integration is disabled")
}
testHandler.cfg.VCSIntegrationEnabled = true
raw, ok := fetch()["vcs_integration_available"]
if !ok {
t.Fatal("vcs_integration_available must be present when the integration is enabled")
}
if string(raw) != "true" {
t.Fatalf("vcs_integration_available: want true, got %s", raw)
}
}
func TestGetConfigUsesAppURLForSameOriginDaemonSetup(t *testing.T) {
t.Setenv("MULTICA_PUBLIC_URL", "")
t.Setenv("MULTICA_APP_URL", "https://multica.internal.example/")

View File

@@ -0,0 +1,175 @@
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")
}
}