mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
fix(vcs): complete self-host integration gating (MUL-5138)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -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 ./...
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
33
scripts/helm-config.test.sh
Normal file
33
scripts/helm-config.test.sh
Normal 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"
|
||||
@@ -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/")
|
||||
|
||||
175
server/internal/handler/vcs_test.go
Normal file
175
server/internal/handler/vcs_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user