mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
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>
This commit is contained in:
13
.env.example
13
.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")
|
||||
|
||||
@@ -5,6 +5,15 @@ description: Connect a self-hosted Forgejo, Gitea, or GitLab instance per worksp
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
<Callout type="warn">
|
||||
**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.
|
||||
</Callout>
|
||||
|
||||
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=<base64 32-byte 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
|
||||
|
||||
@@ -5,6 +5,10 @@ description: 为每个工作区连接自托管的 Forgejo、Gitea 或 GitLab 实
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
<Callout type="warn">
|
||||
**仅适用于自部署的 Multica。** 此集成只在你**自己部署 Multica** 时可用,Multica 云端不提供。这里的「自托管」指的是 **Multica 本身自部署**——通常是为了让它能访问你自己网络内的 Git 实例。需要服务端运维开启(`MULTICA_VCS_INTEGRATION_ENABLED=true`)并设置 `MULTICA_VCS_SECRET_KEY`;在此之前,设置 → 集成里不会出现该板块。
|
||||
</Callout>
|
||||
|
||||
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=<base64 32 字节密钥>
|
||||
```
|
||||
|
||||
两者都必需:开关决定是否提供该功能,密钥用于加密存储的令牌与 Webhook 密钥。连接、Webhook、轮换都要求两者同时满足。
|
||||
|
||||
设置 `MULTICA_PUBLIC_URL` 为服务端的公网基础地址,Multica 即可直接给出可粘贴的 Webhook 地址;否则界面只显示 Webhook 路径,需自行拼接来源地址。
|
||||
|
||||
## 连接工作区
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
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: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
// 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<ConfigState>((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 } }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<SettingsTab title={t(($) => $.page.tabs.integrations)}>
|
||||
<SettingsSection title={t(($) => $.lark.section_title)}>
|
||||
@@ -46,9 +51,11 @@ export function IntegrationsTab() {
|
||||
<SettingsSection title={t(($) => $.slack.section_title)}>
|
||||
<SlackTab />
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t(($) => $.vcs.section_title)}>
|
||||
<VCSTab />
|
||||
</SettingsSection>
|
||||
{vcsAvailable && (
|
||||
<SettingsSection title={t(($) => $.vcs.section_title)}>
|
||||
<VCSTab />
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user