diff --git a/.env.example b/.env.example
index 4241cce022..a7949ee5ad 100644
--- a/.env.example
+++ b/.env.example
@@ -270,12 +270,19 @@ GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Self-hosted Git provider integration (Settings → Integrations): Forgejo, Gitea,
-# and GitLab. Off until MULTICA_VCS_SECRET_KEY is set — a base64-encoded
-# 32-byte key that encrypts each workspace's access token and webhook secret at
-# rest. Leave empty to disable. Generate one with: openssl rand -base64 32
+# and GitLab. This is a SELF-HOSTED-MULTICA-ONLY feature (not offered on the
+# managed cloud). Two settings gate it, and BOTH are required — connect,
+# webhook, and rotate all check both:
+# 1. MULTICA_VCS_INTEGRATION_ENABLED=true turns the feature on for this
+# deployment. Left unset/false the section is hidden entirely (this is how
+# the managed cloud keeps it off). The self-host docker-compose sets it.
+# 2. MULTICA_VCS_SECRET_KEY — a base64-encoded 32-byte key that encrypts each
+# workspace's access token and webhook secret at rest. Generate one with:
+# openssl rand -base64 32
# Unlike GitHub there is no App: each workspace connects its own instance URL
# and access token in the UI, and registers the returned webhook URL/secret on
# its repo or org webhook settings.
+MULTICA_VCS_INTEGRATION_ENABLED=
MULTICA_VCS_SECRET_KEY=
# Lark / Feishu bot integration (Settings → Integrations "Bind to Lark")
diff --git a/apps/docs/content/docs/vcs-integration.mdx b/apps/docs/content/docs/vcs-integration.mdx
index 985eb4e594..1232c09e92 100644
--- a/apps/docs/content/docs/vcs-integration.mdx
+++ b/apps/docs/content/docs/vcs-integration.mdx
@@ -5,6 +5,15 @@ description: Connect a self-hosted Forgejo, Gitea, or GitLab instance per worksp
import { Callout } from "fumadocs-ui/components/callout";
+
+**Self-hosted Multica only.** This integration is available only when you run
+Multica yourself; it is not offered on Multica Cloud. "Self-hosted" here means
+**Multica** is self-hosted — typically so it can reach a Git instance on your own
+network. A server operator must enable it (`MULTICA_VCS_INTEGRATION_ENABLED=true`)
+and set `MULTICA_VCS_SECRET_KEY`; until then the section does not appear in
+Settings → Integrations.
+
+
Multica connects to a self-hosted Git provider per workspace: **Forgejo**, **Gitea**, or **GitLab**. Once connected, any pull request (GitLab: merge request) whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when merged with a closing keyword — moves the issue to **Done**. CI for the head commit shows as a checks bar on the card.
These providers run alongside GitHub: a workspace can use any combination.
@@ -15,7 +24,15 @@ Unlike GitHub, these providers have no "App" model. Each workspace stores its ow
## Prerequisites (server)
-Set a base64-encoded 32-byte key so the server can encrypt stored credentials. Without it, the connect form is disabled.
+Turn the integration on for this deployment. It is off by default, so the section stays hidden until you set:
+
+```sh
+MULTICA_VCS_INTEGRATION_ENABLED=true
+```
+
+The official self-hosted `docker compose` file (`docker-compose.selfhost.yml`) sets this for you.
+
+Then set a base64-encoded 32-byte key so the server can encrypt stored credentials. Without it, the connect form is disabled.
```sh
openssl rand -base64 32
@@ -25,6 +42,8 @@ openssl rand -base64 32
MULTICA_VCS_SECRET_KEY=
```
+Both are required: the switch decides whether the feature is offered, and the key encrypts the stored token and webhook secret. Connect, webhook, and rotate all require both.
+
Set `MULTICA_PUBLIC_URL` to the server's public base URL so Multica can show a ready-to-paste webhook URL. Without it, the UI shows only the webhook path and you prepend your own origin.
## Connect a workspace
diff --git a/apps/docs/content/docs/vcs-integration.zh.mdx b/apps/docs/content/docs/vcs-integration.zh.mdx
index 6d4b6e742d..9b77499b5c 100644
--- a/apps/docs/content/docs/vcs-integration.zh.mdx
+++ b/apps/docs/content/docs/vcs-integration.zh.mdx
@@ -5,6 +5,10 @@ description: 为每个工作区连接自托管的 Forgejo、Gitea 或 GitLab 实
import { Callout } from "fumadocs-ui/components/callout";
+
+**仅适用于自部署的 Multica。** 此集成只在你**自己部署 Multica** 时可用,Multica 云端不提供。这里的「自托管」指的是 **Multica 本身自部署**——通常是为了让它能访问你自己网络内的 Git 实例。需要服务端运维开启(`MULTICA_VCS_INTEGRATION_ENABLED=true`)并设置 `MULTICA_VCS_SECRET_KEY`;在此之前,设置 → 集成里不会出现该板块。
+
+
Multica 按工作区连接自托管的 Git 代码托管:**Forgejo**、**Gitea** 或 **GitLab**。连接后,任何在分支名、标题或正文中包含 issue 编号(例如 `MUL-123`)的 Pull Request(GitLab 为 Merge Request)都会**自动关联**到对应的 [issue](/issues),出现在 issue 侧栏的 **Pull requests** 中;当带有关闭关键字的 PR 合并后,issue 会被移动到「完成」。head commit 的 CI 会以检查条的形式显示在卡片上。
这些代码托管与 GitHub 并行工作:一个工作区可任意组合使用。
@@ -15,7 +19,15 @@ Multica 按工作区连接自托管的 Git 代码托管:**Forgejo**、**Gitea*
## 前置条件(服务端)
-设置一个 base64 编码的 32 字节密钥,供服务端加密存储的凭据。未设置时,连接表单将不可用。
+先为该部署开启此集成。它默认关闭,未开启时该板块不会出现:
+
+```sh
+MULTICA_VCS_INTEGRATION_ENABLED=true
+```
+
+官方自部署 `docker compose` 文件(`docker-compose.selfhost.yml`)已为你设置好。
+
+再设置一个 base64 编码的 32 字节密钥,供服务端加密存储的凭据。未设置时,连接表单将不可用。
```sh
openssl rand -base64 32
@@ -25,6 +37,8 @@ openssl rand -base64 32
MULTICA_VCS_SECRET_KEY=
```
+两者都必需:开关决定是否提供该功能,密钥用于加密存储的令牌与 Webhook 密钥。连接、Webhook、轮换都要求两者同时满足。
+
设置 `MULTICA_PUBLIC_URL` 为服务端的公网基础地址,Multica 即可直接给出可粘贴的 Webhook 地址;否则界面只显示 Webhook 路径,需自行拼接来源地址。
## 连接工作区
diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml
index dcd494a618..f250da46fc 100644
--- a/docker-compose.selfhost.yml
+++ b/docker-compose.selfhost.yml
@@ -119,6 +119,11 @@ services:
# in the database, so this single deployment-wide key is all the operator
# needs to set here.
MULTICA_SLACK_SECRET_KEY: ${MULTICA_SLACK_SECRET_KEY:-}
+ # Self-hosted Git provider integration (Forgejo / Gitea / GitLab). This
+ # is a self-host-only feature, so the compose file turns it on by default;
+ # the managed cloud leaves it unset (off). It still needs a valid
+ # MULTICA_VCS_SECRET_KEY below to actually work.
+ MULTICA_VCS_INTEGRATION_ENABLED: ${MULTICA_VCS_INTEGRATION_ENABLED:-true}
# VCS integration at-rest encryption key for token-based providers
MULTICA_VCS_SECRET_KEY: ${MULTICA_VCS_SECRET_KEY:-}
restart: unless-stopped
diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts
index f1a84b8b69..e6eb955141 100644
--- a/packages/core/api/schemas.ts
+++ b/packages/core/api/schemas.ts
@@ -184,6 +184,10 @@ export interface AppConfigResponse {
daemon_server_url?: string;
daemon_app_url?: string;
workspace_creation_disabled?: boolean;
+ /** Whether this deployment offers the self-hosted Git provider integration
+ * (self-host only; off on the managed cloud). Absent/false hides the whole
+ * Settings → Integrations "Git providers" section. */
+ vcs_integration_available?: boolean;
feature_flags?: Record;
server_version?: string;
}
@@ -334,6 +338,7 @@ export const AppConfigSchema = z.object({
daemon_server_url: OptionalStringSchema,
daemon_app_url: OptionalStringSchema,
workspace_creation_disabled: BooleanWithDefaultSchema(false).optional(),
+ vcs_integration_available: BooleanWithDefaultSchema(false).optional(),
feature_flags: FeatureFlagsSchema,
server_version: OptionalStringSchema,
}).loose();
@@ -346,6 +351,7 @@ export const EMPTY_APP_CONFIG: AppConfigResponse = {
daemon_server_url: "",
daemon_app_url: "",
workspace_creation_disabled: false,
+ vcs_integration_available: false,
feature_flags: {},
};
diff --git a/packages/core/config/index.ts b/packages/core/config/index.ts
index 53d8a5f893..883eb8e802 100644
--- a/packages/core/config/index.ts
+++ b/packages/core/config/index.ts
@@ -15,6 +15,11 @@ interface ConfigState {
// must be hidden. Defaults to false so unknown / older servers behave like
// the managed-cloud case.
workspaceCreationDisabled: boolean;
+ // Self-host-only gate for the Git provider integration (Forgejo / Gitea /
+ // GitLab). When false the whole Settings → Integrations "Git providers"
+ // section is hidden. Defaults to false so unknown / older servers and the
+ // managed cloud (which omits the field) keep it hidden.
+ vcsIntegrationAvailable: boolean;
featureFlags: Record;
// The running API build version, surfaced in the Help popover so
// self-hosted operators can confirm what's deployed. Empty for dev builds
@@ -25,6 +30,7 @@ interface ConfigState {
allowSignup: boolean;
googleClientId?: string;
workspaceCreationDisabled?: boolean;
+ vcsIntegrationAvailable?: boolean;
}) => void;
setDaemonConfig: (config: {
daemonServerUrl?: string;
@@ -42,11 +48,16 @@ export const configStore = createStore((set) => ({
daemonServerUrl: "",
daemonAppUrl: "",
workspaceCreationDisabled: false,
+ vcsIntegrationAvailable: false,
featureFlags: {},
serverVersion: "",
setCdnConfig: ({ cdnDomain, cdnSigned = false }) => set({ cdnDomain, cdnSigned }),
- setAuthConfig: ({ allowSignup, googleClientId = "", workspaceCreationDisabled = false }) =>
- set({ allowSignup, googleClientId, workspaceCreationDisabled }),
+ setAuthConfig: ({
+ allowSignup,
+ googleClientId = "",
+ workspaceCreationDisabled = false,
+ vcsIntegrationAvailable = false,
+ }) => set({ allowSignup, googleClientId, workspaceCreationDisabled, vcsIntegrationAvailable }),
setDaemonConfig: ({ daemonServerUrl = "", daemonAppUrl = "" }) =>
set({ daemonServerUrl, daemonAppUrl }),
setFeatureFlags: (flags = {}) => set({ featureFlags: { ...flags } }),
diff --git a/packages/core/platform/auth-initializer.tsx b/packages/core/platform/auth-initializer.tsx
index 3e127a0683..8bdf65d423 100644
--- a/packages/core/platform/auth-initializer.tsx
+++ b/packages/core/platform/auth-initializer.tsx
@@ -62,6 +62,8 @@ export function AuthInitializer({
// Old servers omit this field — treat that as "creation allowed"
// (the managed-cloud default) rather than blocking the UI.
workspaceCreationDisabled: cfg.workspace_creation_disabled === true,
+ // Absent/false on the managed cloud and older servers → section hidden.
+ vcsIntegrationAvailable: cfg.vcs_integration_available === true,
});
configStore.getState().setDaemonConfig({
daemonServerUrl: cfg.daemon_server_url,
diff --git a/packages/core/types/vcs.ts b/packages/core/types/vcs.ts
index 04d7dfaba0..2231831ddb 100644
--- a/packages/core/types/vcs.ts
+++ b/packages/core/types/vcs.ts
@@ -25,6 +25,12 @@ export interface VCSConnection {
export interface ListVCSConnectionsResponse {
connections: VCSConnection[];
+ /** Whether this deployment offers the integration at all (self-host only;
+ * off on the managed cloud). When false the section is hidden entirely.
+ * Older backends omit it; treat as true so the existing self-host UI still
+ * renders (visibility is also gated by vcs_integration_available on
+ * /api/config, which is the authoritative deployment signal). */
+ available?: boolean;
/** Whether the deployment has MULTICA_VCS_SECRET_KEY configured. When false
* the connect form is disabled. Older backends omit it; treat as false. */
configured?: boolean;
diff --git a/packages/views/settings/components/integrations-tab.test.tsx b/packages/views/settings/components/integrations-tab.test.tsx
index 089201df21..7db6ab51fc 100644
--- a/packages/views/settings/components/integrations-tab.test.tsx
+++ b/packages/views/settings/components/integrations-tab.test.tsx
@@ -63,6 +63,9 @@ describe("Settings IntegrationsTab", () => {
queryCallsRef.current = [];
composioErrorRef.current = null;
configStore.getState().setFeatureFlags({ [COMPOSIO_MCP_APPS_FLAG]: true });
+ // Reset the self-host-only VCS gate to its default (hidden) so tests stay
+ // isolated; individual tests opt in below.
+ configStore.getState().setAuthConfig({ allowSignup: true, vcsIntegrationAvailable: false });
});
it("hides Composio and disables the toolkits query when the feature flag is off", () => {
@@ -89,4 +92,19 @@ describe("Settings IntegrationsTab", () => {
expect(screen.queryByTestId("composio-tab")).toBeNull();
});
+
+ it("hides the Git providers section when the deployment reports it unavailable", () => {
+ // Default (managed cloud / older server): vcsIntegrationAvailable is false.
+ renderTab();
+
+ expect(screen.queryByTestId("vcs-tab")).toBeNull();
+ });
+
+ it("shows the Git providers section on a self-hosted deployment that enables it", () => {
+ configStore.getState().setAuthConfig({ allowSignup: true, vcsIntegrationAvailable: true });
+
+ renderTab();
+
+ expect(screen.getByTestId("vcs-tab")).toBeInTheDocument();
+ });
});
diff --git a/packages/views/settings/components/integrations-tab.tsx b/packages/views/settings/components/integrations-tab.tsx
index 5b8eaf39f6..03d8025f35 100644
--- a/packages/views/settings/components/integrations-tab.tsx
+++ b/packages/views/settings/components/integrations-tab.tsx
@@ -7,7 +7,7 @@ import { SlackTab } from "./slack-tab";
import { VCSTab } from "./vcs-tab";
import { ApiError } from "@multica/core/api";
import { composioToolkitsOptions } from "@multica/core/composio";
-import { useFeatureEnabled } from "@multica/core/config";
+import { useConfigStore, useFeatureEnabled } from "@multica/core/config";
import { COMPOSIO_MCP_APPS_FLAG } from "@multica/core/feature-flags";
import { useT } from "../../i18n";
import { SettingsSection, SettingsTab } from "./settings-layout";
@@ -33,6 +33,11 @@ export function IntegrationsTab() {
const composioUnconfigured =
composioToolkits.error instanceof ApiError && composioToolkits.error.status === 503;
+ // Self-host-only integration: the managed cloud reports this false (field
+ // omitted from /api/config), so the whole section — header included — is
+ // hidden there rather than showing an operator-only "missing key" message.
+ const vcsAvailable = useConfigStore((s) => s.vcsIntegrationAvailable);
+
return (
$.page.tabs.integrations)}>
$.lark.section_title)}>
@@ -46,9 +51,11 @@ export function IntegrationsTab() {
$.slack.section_title)}>
- $.vcs.section_title)}>
-
-
+ {vcsAvailable && (
+ $.vcs.section_title)}>
+
+
+ )}
);
}
diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go
index f9ab30ce0a..c48bda7196 100644
--- a/server/cmd/server/router.go
+++ b/server/cmd/server/router.go
@@ -204,6 +204,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
AllowedEmails: splitAndTrim(os.Getenv("ALLOWED_EMAILS")),
AllowedEmailDomains: splitAndTrim(os.Getenv("ALLOWED_EMAIL_DOMAINS")),
DisableWorkspaceCreation: os.Getenv("DISABLE_WORKSPACE_CREATION") == "true",
+ VCSIntegrationEnabled: os.Getenv("MULTICA_VCS_INTEGRATION_ENABLED") == "true",
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MULTICA_PUBLIC_URL")), "/"),
TrustedProxies: parseTrustedProxies(os.Getenv("MULTICA_TRUSTED_PROXIES")),
CloudRuntimeFleetURL: cloudRuntimeFleetURLFromEnv(),
diff --git a/server/internal/handler/config.go b/server/internal/handler/config.go
index a0a9c7f91f..58bf5c5719 100644
--- a/server/internal/handler/config.go
+++ b/server/internal/handler/config.go
@@ -37,6 +37,15 @@ type AppConfig struct {
DaemonServerURL string `json:"daemon_server_url,omitempty"`
DaemonAppURL string `json:"daemon_app_url,omitempty"`
+ // VCSIntegrationAvailable mirrors the MULTICA_VCS_INTEGRATION_ENABLED
+ // deployment switch so the Settings UI can hide the whole self-hosted Git
+ // provider section on deployments where it is off (the managed cloud),
+ // instead of rendering it and surfacing an operator-only "missing
+ // MULTICA_VCS_SECRET_KEY" hint a cloud user cannot resolve. Omitted when
+ // false so the managed-cloud response keeps its previous shape; the UI
+ // defaults absent to false (hidden).
+ VCSIntegrationAvailable bool `json:"vcs_integration_available,omitempty"`
+
// PostHog public config for the frontend. The key is the same Project
// API Key the backend uses; returning it here (instead of baking it
// into the frontend bundle via NEXT_PUBLIC_*) means self-hosted
@@ -73,6 +82,7 @@ func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
}
config.CdnSigned = h.CFSigner != nil
config.DaemonServerURL, config.DaemonAppURL = daemonSetupURLsFromEnv()
+ config.VCSIntegrationAvailable = h.cfg.VCSIntegrationEnabled
config.FeatureFlags = featureflags.EvaluateFrontendPublicFlags(r.Context(), h.FeatureFlags)
// Only surface the build version on self-hosted deployments. The managed
// cloud is continuously deployed and its users can't choose the build, so
diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go
index a3c9337b9b..4a87c901a1 100644
--- a/server/internal/handler/handler.go
+++ b/server/internal/handler/handler.go
@@ -66,6 +66,16 @@ type Config struct {
// invitation only. The public /api/config endpoint mirrors this flag so
// the UI can hide every "Create workspace" affordance — see #3433.
DisableWorkspaceCreation bool
+ // VCSIntegrationEnabled gates the self-hosted Git provider integration
+ // (Forgejo / Gitea / GitLab) at the deployment level, independent of whether
+ // MULTICA_VCS_SECRET_KEY is set. It is the product boundary: the feature is
+ // intended for self-hosted Multica only (where Multica and the Git instance
+ // can share a network), and is left off on the managed cloud — connect,
+ // rotate, and webhook handlers reject when it is false, and /api/config
+ // omits it so the UI hides the whole section rather than showing a
+ // "missing key" message a cloud user cannot act on. Populated from
+ // MULTICA_VCS_INTEGRATION_ENABLED; the self-host compose defaults it on.
+ VCSIntegrationEnabled bool
// PublicURL is the absolute base URL the API is reachable at from the
// public internet, with no trailing slash (e.g. "https://multica.ai").
// Used only to build webhook_url responses for autopilot webhook triggers
diff --git a/server/internal/handler/vcs.go b/server/internal/handler/vcs.go
index d17e9b3c25..0a4cbbe387 100644
--- a/server/internal/handler/vcs.go
+++ b/server/internal/handler/vcs.go
@@ -44,6 +44,13 @@ type VCSConnectResponse struct {
const vcsWebhookPathPrefix = "/api/webhooks/vcs/"
+// isVCSAvailable reports whether this deployment offers the self-hosted Git
+// provider integration at all. It is the product boundary (self-host only) and
+// is independent of isVCSConfigured (whether the encryption key is set): the
+// managed cloud leaves it off, so connect/rotate/webhook reject and the UI
+// hides the section rather than surfacing an operator-only "missing key" hint.
+func (h *Handler) isVCSAvailable() bool { return h.cfg.VCSIntegrationEnabled }
+
func (h *Handler) isVCSConfigured() bool { return h.VCSSecretBox != nil }
func (h *Handler) vcsWebhookPath(connID string) string { return vcsWebhookPathPrefix + connID }
@@ -106,6 +113,20 @@ func (h *Handler) ListVCSConnections(w http.ResponseWriter, r *http.Request) {
member, _ := middleware.MemberFromContext(r.Context())
canManage := roleAllowed(member.Role, "owner", "admin")
+ // Deployments where the integration is off (the managed cloud) report
+ // available=false and nothing else, so the UI hides the whole section
+ // instead of showing an operator-only "missing key" hint. No connections
+ // can exist there anyway (connect is rejected), so skip the query.
+ if !h.isVCSAvailable() {
+ writeJSON(w, http.StatusOK, map[string]any{
+ "connections": []VCSConnectionResponse{},
+ "available": false,
+ "configured": false,
+ "can_manage": false,
+ })
+ return
+ }
+
rows, err := h.Queries.ListVCSConnectionsByWorkspace(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list connections")
@@ -117,6 +138,7 @@ func (h *Handler) ListVCSConnections(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"connections": out,
+ "available": true,
"configured": h.isVCSConfigured(),
"can_manage": canManage,
})
@@ -138,6 +160,10 @@ func (h *Handler) ConnectVCS(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ if !h.isVCSAvailable() {
+ writeError(w, http.StatusNotFound, "vcs integration is not available on this deployment")
+ return
+ }
if !h.isVCSConfigured() {
writeError(w, http.StatusServiceUnavailable, "vcs integration not configured (MULTICA_VCS_SECRET_KEY unset)")
return
@@ -256,6 +282,10 @@ func (h *Handler) RotateVCSConnectionWebhook(w http.ResponseWriter, r *http.Requ
if !ok {
return
}
+ if !h.isVCSAvailable() {
+ writeError(w, http.StatusNotFound, "vcs integration is not available on this deployment")
+ return
+ }
if !h.isVCSConfigured() {
writeError(w, http.StatusServiceUnavailable, "vcs integration not configured (MULTICA_VCS_SECRET_KEY unset)")
return
diff --git a/server/internal/handler/vcs_webhook.go b/server/internal/handler/vcs_webhook.go
index 45a6ebf3f7..ca357fc70d 100644
--- a/server/internal/handler/vcs_webhook.go
+++ b/server/internal/handler/vcs_webhook.go
@@ -85,6 +85,13 @@ func vcsPullRequestRowToResponse(p db.ListVCSPullRequestsByIssueRow) GitHubPullR
// adapter handles the provider-specific signature scheme, event header, and
// payload shape, returning normalized events to the shared mirror logic below.
func (h *Handler) HandleVCSWebhook(w http.ResponseWriter, r *http.Request) {
+ // Where the integration is off (the managed cloud) the endpoint behaves as
+ // if it does not exist — a bare 404 that reveals nothing about config, the
+ // same response a genuinely unknown connection id gets below.
+ if !h.isVCSAvailable() {
+ writeError(w, http.StatusNotFound, "unknown connection")
+ return
+ }
if !h.isVCSConfigured() {
writeError(w, http.StatusServiceUnavailable, "vcs webhooks not configured")
return
diff --git a/server/internal/handler/vcs_webhook_test.go b/server/internal/handler/vcs_webhook_test.go
index 67078e773a..074c935f65 100644
--- a/server/internal/handler/vcs_webhook_test.go
+++ b/server/internal/handler/vcs_webhook_test.go
@@ -27,7 +27,14 @@ func withVCSBox(t *testing.T) *secretbox.Box {
}
prev := testHandler.VCSSecretBox
testHandler.VCSSecretBox = box
- t.Cleanup(func() { testHandler.VCSSecretBox = prev })
+ // The feature also requires the deployment-level switch; the box alone is
+ // no longer sufficient. Enable it for the "configured" path tests.
+ prevEnabled := testHandler.cfg.VCSIntegrationEnabled
+ testHandler.cfg.VCSIntegrationEnabled = true
+ t.Cleanup(func() {
+ testHandler.VCSSecretBox = prev
+ testHandler.cfg.VCSIntegrationEnabled = prevEnabled
+ })
return box
}
@@ -504,6 +511,31 @@ func TestVCSWebhook_UnknownConnection(t *testing.T) {
}
}
+// TestVCSWebhook_DisabledDeploymentReturns404 verifies the deployment-level
+// switch is enforced server-side: with the integration off (the managed-cloud
+// posture), even a valid, correctly-signed delivery to a real connection is
+// rejected with a bare 404, so the feature is never processed and reveals
+// nothing about config. The availability gate short-circuits before signature
+// verification.
+func TestVCSWebhook_DisabledDeploymentReturns404(t *testing.T) {
+ ctx := context.Background()
+ box := withVCSBox(t) // sets the box AND enables the switch
+ connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
+ t.Cleanup(func() { cleanupVCS(ctx, "") })
+
+ // Now flip the deployment switch off (withVCSBox's cleanup restores it).
+ testHandler.cfg.VCSIntegrationEnabled = false
+
+ raw := []byte(`{"action":"opened","pull_request":{"number":1}}`)
+ w := httptest.NewRecorder()
+ testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
+ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw),
+ }, raw))
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404 when integration disabled, got %d (%s)", w.Code, w.Body.String())
+ }
+}
+
func TestVCSWebhook_MalformedTolerated(t *testing.T) {
ctx := context.Background()
box := withVCSBox(t)