diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e47d39eb8..d90b7be42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/deploy/helm/multica/templates/configmap.yaml b/deploy/helm/multica/templates/configmap.yaml index 843f5209f..7d2a8620c 100644 --- a/deploy/helm/multica/templates/configmap.yaml +++ b/deploy/helm/multica/templates/configmap.yaml @@ -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 }} diff --git a/deploy/helm/multica/values.yaml b/deploy/helm/multica/values.yaml index ce2600d7d..a593b41e8 100644 --- a/deploy/helm/multica/values.yaml +++ b/deploy/helm/multica/values.yaml @@ -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: "" diff --git a/scripts/helm-config.test.sh b/scripts/helm-config.test.sh new file mode 100644 index 000000000..9d73ee56a --- /dev/null +++ b/scripts/helm-config.test.sh @@ -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" diff --git a/server/internal/handler/config_test.go b/server/internal/handler/config_test.go index d6dd3977a..5cef29e32 100644 --- a/server/internal/handler/config_test.go +++ b/server/internal/handler/config_test.go @@ -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/") diff --git a/server/internal/handler/vcs_test.go b/server/internal/handler/vcs_test.go new file mode 100644 index 000000000..9fac3680e --- /dev/null +++ b/server/internal/handler/vcs_test.go @@ -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") + } +}