mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* fix(onboarding): show self-host setup command when daemon URLs configured (fixes #8425) * fix(onboarding): fail closed until daemon setup target is confirmed (MUL-7384) Co-authored-by: multica-agent <[email protected]> * fix(onboarding): preserve legacy self-host setup commands (MUL-7384) Co-authored-by: multica-agent <[email protected]> * fix(i18n): restore Traditional Chinese locale parity (MUL-7384) Co-authored-by: multica-agent <[email protected]> * test(daemon): pin Pi upstream cancellation handoff (MUL-7384) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]>
657 lines
24 KiB
Go
657 lines
24 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/multica-ai/multica/server/internal/auth"
|
||
"github.com/multica-ai/multica/server/internal/testutil"
|
||
)
|
||
|
||
func TestGetConfigReportsCdnSignedMode(t *testing.T) {
|
||
origStorage := testHandler.Storage
|
||
origSigner := testHandler.CFSigner
|
||
testHandler.Storage = &mockStorage{}
|
||
defer func() {
|
||
testHandler.Storage = origStorage
|
||
testHandler.CFSigner = origSigner
|
||
}()
|
||
|
||
fetch := func() AppConfig {
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
testHandler.CFSigner = nil
|
||
if cfg := fetch(); cfg.CdnSigned {
|
||
t.Fatalf("cdn_signed: want false without a CloudFront signer, got true")
|
||
}
|
||
|
||
// With signing enabled the same cdn_domain serves private content via
|
||
// signed URLs only — clients must be told raw storage URLs won't load.
|
||
testHandler.CFSigner = &auth.CloudFrontSigner{}
|
||
cfg := fetch()
|
||
if !cfg.CdnSigned {
|
||
t.Fatalf("cdn_signed: want true with a CloudFront signer, got false")
|
||
}
|
||
if cfg.CdnDomain != "cdn.example.com" {
|
||
t.Fatalf("cdn_domain: want cdn.example.com alongside cdn_signed, got %q", cfg.CdnDomain)
|
||
}
|
||
}
|
||
|
||
func TestGetConfigIncludesRuntimeAuthConfig(t *testing.T) {
|
||
origStorage := testHandler.Storage
|
||
origCfg := testHandler.cfg
|
||
testHandler.Storage = &mockStorage{}
|
||
testHandler.cfg.PublicURL = "https://api.example.com"
|
||
defer func() {
|
||
testHandler.Storage = origStorage
|
||
testHandler.cfg = origCfg
|
||
}()
|
||
|
||
t.Setenv("ALLOW_SIGNUP", "false")
|
||
t.Setenv("GOOGLE_CLIENT_ID", "google-client-id")
|
||
t.Setenv("POSTHOG_API_KEY", "phc_test")
|
||
t.Setenv("POSTHOG_HOST", "https://eu.i.posthog.com")
|
||
t.Setenv("MULTICA_DAEMON_SERVER_URL", "")
|
||
t.Setenv("MULTICA_PUBLIC_URL", "https://api.example.com/")
|
||
t.Setenv("MULTICA_APP_URL", "https://app.example.com/")
|
||
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
|
||
if cfg.CdnDomain != "cdn.example.com" {
|
||
t.Fatalf("cdn_domain: want cdn.example.com, got %q", cfg.CdnDomain)
|
||
}
|
||
if cfg.AttachmentPublicURL != "https://api.example.com" {
|
||
t.Fatalf("attachment_public_url = %q, want API public URL", cfg.AttachmentPublicURL)
|
||
}
|
||
if cfg.AllowSignup {
|
||
t.Fatalf("allow_signup: want false, got true")
|
||
}
|
||
if cfg.GoogleClientID != "google-client-id" {
|
||
t.Fatalf("google_client_id: want google-client-id, got %q", cfg.GoogleClientID)
|
||
}
|
||
if cfg.PosthogKey != "phc_test" {
|
||
t.Fatalf("posthog_key: want phc_test, got %q", cfg.PosthogKey)
|
||
}
|
||
if cfg.PosthogHost != "https://eu.i.posthog.com" {
|
||
t.Fatalf("posthog_host: want https://eu.i.posthog.com, got %q", cfg.PosthogHost)
|
||
}
|
||
if cfg.AnalyticsEnvironment != "dev" {
|
||
t.Fatalf("analytics_environment: want dev, got %q", cfg.AnalyticsEnvironment)
|
||
}
|
||
if cfg.WorkspaceCreationDisabled {
|
||
t.Fatalf("workspace_creation_disabled: want false by default, got true")
|
||
}
|
||
if cfg.DaemonServerURL != "https://api.example.com" {
|
||
t.Fatalf("daemon_server_url: want https://api.example.com, got %q", cfg.DaemonServerURL)
|
||
}
|
||
if cfg.DaemonAppURL != "https://app.example.com" {
|
||
t.Fatalf("daemon_app_url: want https://app.example.com, got %q", cfg.DaemonAppURL)
|
||
}
|
||
}
|
||
|
||
func TestGetConfigUsesDaemonServerURLOverride(t *testing.T) {
|
||
t.Setenv("MULTICA_DAEMON_SERVER_URL", " https://api.internal.example/// ")
|
||
t.Setenv("MULTICA_PUBLIC_URL", "https://hooks.example.com/")
|
||
t.Setenv("MULTICA_APP_URL", "https://app.example.com/")
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||
var cfg AppConfig
|
||
testutil.Call(t, testHandler.GetConfig, req).Want(http.StatusOK).JSON(&cfg)
|
||
if cfg.DaemonServerURL != "https://api.internal.example" {
|
||
t.Fatalf("daemon_server_url: want override URL, got %q", cfg.DaemonServerURL)
|
||
}
|
||
if cfg.DaemonAppURL != "https://app.example.com" {
|
||
t.Fatalf("daemon_app_url: want app URL, got %q", cfg.DaemonAppURL)
|
||
}
|
||
}
|
||
|
||
func TestGetConfigDeclaresDaemonSetupTarget(t *testing.T) {
|
||
for _, tt := range []struct {
|
||
name, appURL, wantTarget, wantServerURL string
|
||
}{
|
||
{"official Cloud", "https://multica.ai", "cloud", ""},
|
||
{"configured self-host", "https://app.example.com", "self_host", "https://api.example.com"},
|
||
{"incomplete self-host", "", "self_host", ""},
|
||
} {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
t.Setenv("MULTICA_APP_URL", tt.appURL)
|
||
t.Setenv("FRONTEND_ORIGIN", "")
|
||
t.Setenv("MULTICA_PUBLIC_URL", "https://api.example.com")
|
||
t.Setenv("MULTICA_DAEMON_SERVER_URL", "")
|
||
var cfg AppConfig
|
||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||
testutil.Call(t, testHandler.GetConfig, req).Want(http.StatusOK).JSON(&cfg)
|
||
if cfg.DaemonSetupTarget != tt.wantTarget {
|
||
t.Fatalf("daemon_setup_target = %q, want %q", cfg.DaemonSetupTarget, tt.wantTarget)
|
||
}
|
||
if cfg.DaemonServerURL != tt.wantServerURL {
|
||
t.Fatalf("daemon_server_url = %q, want %q", cfg.DaemonServerURL, tt.wantServerURL)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
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_DAEMON_SERVER_URL", "")
|
||
t.Setenv("MULTICA_PUBLIC_URL", "")
|
||
t.Setenv("MULTICA_APP_URL", "https://multica.internal.example/")
|
||
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if cfg.DaemonServerURL != "https://multica.internal.example" {
|
||
t.Fatalf("daemon_server_url: want same-origin URL, got %q", cfg.DaemonServerURL)
|
||
}
|
||
if cfg.DaemonAppURL != "https://multica.internal.example" {
|
||
t.Fatalf("daemon_app_url: want app URL, got %q", cfg.DaemonAppURL)
|
||
}
|
||
}
|
||
|
||
func TestGetConfigUsesFrontendOriginForSameOriginDaemonSetup(t *testing.T) {
|
||
t.Setenv("MULTICA_DAEMON_SERVER_URL", "")
|
||
t.Setenv("MULTICA_PUBLIC_URL", "")
|
||
t.Setenv("MULTICA_APP_URL", "")
|
||
t.Setenv("FRONTEND_ORIGIN", "https://multica.internal.example/")
|
||
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if cfg.DaemonServerURL != "https://multica.internal.example" {
|
||
t.Fatalf("daemon_server_url: want same-origin URL, got %q", cfg.DaemonServerURL)
|
||
}
|
||
if cfg.DaemonAppURL != "https://multica.internal.example" {
|
||
t.Fatalf("daemon_app_url: want frontend origin, got %q", cfg.DaemonAppURL)
|
||
}
|
||
}
|
||
|
||
func TestGetConfigOmitsOfficialCloudDaemonSetup(t *testing.T) {
|
||
t.Setenv("MULTICA_PUBLIC_URL", "https://api.multica.ai")
|
||
t.Setenv("MULTICA_APP_URL", "")
|
||
t.Setenv("FRONTEND_ORIGIN", "https://multica.ai")
|
||
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if cfg.DaemonServerURL != "" {
|
||
t.Fatalf("daemon_server_url: want omitted for cloud, got %q", cfg.DaemonServerURL)
|
||
}
|
||
if cfg.DaemonAppURL != "" {
|
||
t.Fatalf("daemon_app_url: want omitted for cloud, got %q", cfg.DaemonAppURL)
|
||
}
|
||
}
|
||
|
||
// TestGetConfigOmitsCloudDaemonSetupWithoutPublicURL reproduces the production
|
||
// regression behind the broken "Add a computer" command: the official cloud
|
||
// frontend is multica.ai, but the deployment does not set MULTICA_PUBLIC_URL to
|
||
// the api host. Previously this fell through to the same-origin branch and
|
||
// emitted daemon_server_url=https://multica.ai, which the dialog turned into
|
||
// `multica setup self-host --server-url https://multica.ai` — pointing the
|
||
// daemon's backend at the frontend (no /health, no WebSocket proxy). The
|
||
// official cloud must be recognised by its frontend host alone so the daemon
|
||
// setup URLs are omitted and the dialog falls back to `multica setup`.
|
||
func TestGetConfigOmitsCloudDaemonSetupWithoutPublicURL(t *testing.T) {
|
||
t.Setenv("MULTICA_PUBLIC_URL", "")
|
||
t.Setenv("MULTICA_APP_URL", "")
|
||
t.Setenv("FRONTEND_ORIGIN", "https://multica.ai")
|
||
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if cfg.DaemonServerURL != "" {
|
||
t.Fatalf("daemon_server_url: want omitted for official cloud, got %q", cfg.DaemonServerURL)
|
||
}
|
||
if cfg.DaemonAppURL != "" {
|
||
t.Fatalf("daemon_app_url: want omitted for official cloud, got %q", cfg.DaemonAppURL)
|
||
}
|
||
}
|
||
|
||
// TestGetConfigOmitsCloudDaemonSetupForConfiguredAppURL covers the official
|
||
// cloud frontend when it is configured through MULTICA_APP_URL.
|
||
func TestGetConfigOmitsCloudDaemonSetupForConfiguredAppURL(t *testing.T) {
|
||
t.Setenv("MULTICA_PUBLIC_URL", "")
|
||
t.Setenv("MULTICA_APP_URL", "https://multica.ai")
|
||
t.Setenv("FRONTEND_ORIGIN", "")
|
||
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if cfg.DaemonServerURL != "" {
|
||
t.Fatalf("daemon_server_url: want omitted for official cloud, got %q", cfg.DaemonServerURL)
|
||
}
|
||
if cfg.DaemonAppURL != "" {
|
||
t.Fatalf("daemon_app_url: want omitted for official cloud, got %q", cfg.DaemonAppURL)
|
||
}
|
||
}
|
||
|
||
func TestURLHostEqualsCanonicalizesCommonHostForms(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
raw string
|
||
want bool
|
||
}{
|
||
{name: "full URL", raw: "https://api.multica.ai", want: true},
|
||
{name: "bare host", raw: "api.multica.ai", want: true},
|
||
{name: "host port", raw: "api.multica.ai:8080", want: true},
|
||
{name: "trailing dot", raw: "https://api.multica.ai.", want: true},
|
||
{name: "different host", raw: "https://evil.example", want: false},
|
||
{name: "empty", raw: "", want: false},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
if got := urlHostEquals(tt.raw, "api.multica.ai"); got != tt.want {
|
||
t.Fatalf("urlHostEquals(%q): want %v, got %v", tt.raw, tt.want, got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestGetConfigExposesWorkspaceCreationDisabled verifies that the self-host
|
||
// gate added by #3433 surfaces to the frontend through /api/config so the UI
|
||
// can hide every "Create workspace" affordance.
|
||
func TestGetConfigExposesWorkspaceCreationDisabled(t *testing.T) {
|
||
origStorage := testHandler.Storage
|
||
testHandler.Storage = &mockStorage{}
|
||
defer func() { testHandler.Storage = origStorage }()
|
||
|
||
t.Setenv("DISABLE_WORKSPACE_CREATION", "true")
|
||
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if !cfg.WorkspaceCreationDisabled {
|
||
t.Fatalf("workspace_creation_disabled: want true with env on, got false (body=%s)", w.Body.String())
|
||
}
|
||
}
|
||
|
||
// TestGetConfigExposesServerVersion verifies /api/config reports the build
|
||
// version main.go stamps into handler.Config via -X main.version on a
|
||
// self-hosted deployment, so operators can confirm what's deployed from the
|
||
// Help popover.
|
||
func TestGetConfigExposesServerVersion(t *testing.T) {
|
||
origCfg := testHandler.cfg
|
||
defer func() { testHandler.cfg = origCfg }()
|
||
|
||
// Self-hosted frontend origin: the version row is meant for these deployments.
|
||
t.Setenv("MULTICA_APP_URL", "https://multica.self-hosted.example")
|
||
t.Setenv("FRONTEND_ORIGIN", "")
|
||
|
||
testHandler.cfg.ServerVersion = ""
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if cfg.ServerVersion != "" {
|
||
t.Fatalf("server_version: want empty for dev build, got %q", cfg.ServerVersion)
|
||
}
|
||
|
||
testHandler.cfg.ServerVersion = "1.2.3"
|
||
w = httptest.NewRecorder()
|
||
testHandler.GetConfig(w, req)
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if cfg.ServerVersion != "1.2.3" {
|
||
t.Fatalf("server_version: want 1.2.3, got %q", cfg.ServerVersion)
|
||
}
|
||
}
|
||
|
||
// TestGetConfigOmitsServerVersionOnOfficialCloud verifies the build version is
|
||
// suppressed on the managed cloud (frontend host multica.ai) even when the
|
||
// binary is stamped, while a self-hosted frontend origin still reports it. The
|
||
// managed cloud is continuously deployed, so its users don't need the row.
|
||
func TestGetConfigOmitsServerVersionOnOfficialCloud(t *testing.T) {
|
||
origCfg := testHandler.cfg
|
||
defer func() { testHandler.cfg = origCfg }()
|
||
testHandler.cfg.ServerVersion = "1.2.3"
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||
|
||
// Official cloud: frontend host multica.ai -> version omitted.
|
||
t.Setenv("MULTICA_APP_URL", "https://multica.ai")
|
||
t.Setenv("FRONTEND_ORIGIN", "")
|
||
w := httptest.NewRecorder()
|
||
testHandler.GetConfig(w, req)
|
||
var cfg AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if cfg.ServerVersion != "" {
|
||
t.Fatalf("server_version: want omitted on official cloud, got %q", cfg.ServerVersion)
|
||
}
|
||
|
||
// Self-hosted: operator's own frontend origin -> version reported.
|
||
t.Setenv("MULTICA_APP_URL", "https://multica.self-hosted.example")
|
||
w = httptest.NewRecorder()
|
||
testHandler.GetConfig(w, req)
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if cfg.ServerVersion != "1.2.3" {
|
||
t.Fatalf("server_version: want 1.2.3 on self-hosted, got %q", cfg.ServerVersion)
|
||
}
|
||
}
|
||
|
||
func TestGetConfigExposesFrontendFeatureFlags(t *testing.T) {
|
||
h := &Handler{}
|
||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||
w := httptest.NewRecorder()
|
||
h.GetConfig(w, req)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("GetConfig default flags: expected 200, got %d: %s", w.Code, w.Body.String())
|
||
}
|
||
var cfg AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode default config: %v", err)
|
||
}
|
||
if cfg.FeatureFlags["composio_mcp_apps"] {
|
||
t.Fatalf("composio_mcp_apps: want false by default, got true")
|
||
}
|
||
if cfg.FeatureFlags["billing_workspace_subscriptions"] {
|
||
t.Fatalf("billing_workspace_subscriptions: want false by default, got true")
|
||
}
|
||
if cfg.FeatureFlags["plugins_v1"] {
|
||
t.Fatalf("plugins_v1: want false by default, got true")
|
||
}
|
||
for _, retired := range []string{"private_plugins_v1", "remote_mcp_plugins_v1"} {
|
||
if _, published := cfg.FeatureFlags[retired]; published {
|
||
t.Fatalf("retired Plugin sub-flag %q must not be published", retired)
|
||
}
|
||
}
|
||
if !cfg.FeatureFlags["agents_skill_toggles"] {
|
||
t.Fatalf("agents_skill_toggles: want true for installed v0.4.0 clients, got false")
|
||
}
|
||
if !cfg.FeatureFlags["settings_resource_labels"] {
|
||
t.Fatalf("settings_resource_labels: want true for installed clients, got false")
|
||
}
|
||
if !cfg.FeatureFlags["project_workflows_v1"] {
|
||
t.Fatal("project_workflows_v1: want true for installed clients")
|
||
}
|
||
if !cfg.FeatureFlags["agents_agent_builder"] {
|
||
t.Fatalf("agents_agent_builder: want true for installed clients, got false")
|
||
}
|
||
// MUL-5345: hang stack capture is gone from this build, but v0.4.13–v0.4.18 are
|
||
// installed and still hold a debugger channel open on every renderer whenever
|
||
// this key arrives as `true`. Those clients are fail-closed on absence, so NOT
|
||
// publishing the key is what disarms them — re-adding it would put a flag flip
|
||
// back within reach of a fleet that can no longer produce a usable stack.
|
||
if _, published := cfg.FeatureFlags["desktop_hang_stack_capture"]; published {
|
||
t.Fatalf("desktop_hang_stack_capture: must stay unpublished so installed clients keep their debugger channels closed")
|
||
}
|
||
// Deliberately unpublished: pre-v0.4.33 clients gate their "New status"
|
||
// button on this key and fail closed, which is how a client that predates
|
||
// the v0.4.31 rendering fixes is kept from creating one.
|
||
if _, published := cfg.FeatureFlags["custom_issue_statuses"]; published {
|
||
t.Fatalf("custom_issue_statuses: want unpublished, got %v", cfg.FeatureFlags["custom_issue_statuses"])
|
||
}
|
||
|
||
withComposioMCPAppsFlag(t, h, true)
|
||
w = httptest.NewRecorder()
|
||
h.GetConfig(w, req)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("GetConfig enabled flags: expected 200, got %d: %s", w.Code, w.Body.String())
|
||
}
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode enabled config: %v", err)
|
||
}
|
||
if !cfg.FeatureFlags["composio_mcp_apps"] {
|
||
t.Fatalf("composio_mcp_apps: want true with flag enabled, got false")
|
||
}
|
||
}
|
||
|
||
func TestGetConfigExposesEnabledPluginsV1Flag(t *testing.T) {
|
||
h := &Handler{}
|
||
withPluginsV1Flag(t, h, true)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||
w := httptest.NewRecorder()
|
||
h.GetConfig(w, req)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("GetConfig enabled plugins_v1: expected 200, got %d: %s", w.Code, w.Body.String())
|
||
}
|
||
var cfg AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode enabled config: %v", err)
|
||
}
|
||
if !cfg.FeatureFlags["plugins_v1"] {
|
||
t.Fatal("plugins_v1: want true with flag enabled, got false")
|
||
}
|
||
}
|
||
|
||
func TestGetConfigKeepsProjectWorkflowsEnabledForInstalledClients(t *testing.T) {
|
||
h := &Handler{}
|
||
withFeatureFlag(t, h, "project_workflows_v1", false)
|
||
|
||
w := httptest.NewRecorder()
|
||
h.GetConfig(w, httptest.NewRequest(http.MethodGet, "/api/config", nil))
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("GetConfig: expected 200, got %d: %s", w.Code, w.Body.String())
|
||
}
|
||
var cfg AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if !cfg.FeatureFlags["project_workflows_v1"] {
|
||
t.Fatal("retired project_workflows_v1 overrides must not hide workflows in installed clients")
|
||
}
|
||
}
|
||
|
||
// Clients fail closed on this flag: absent covers every server that predates
|
||
// the signal, including the ones that accept a worktree resource, silently drop
|
||
// execution_mode and run the task in the user's working copy (#7113). A build
|
||
// that HAS the save gate therefore has to say so,
|
||
// unconditionally — not behind a deployment check, an env var or a feature
|
||
// flag, all of which would disable worktree mode for the users who can run it.
|
||
func TestGetConfigDeclaresLocalWorktreeSupport(t *testing.T) {
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if !cfg.LocalWorktreeSupported {
|
||
t.Fatal("this build runs the worktree save gate but does not advertise it; clients will hide the mode")
|
||
}
|
||
// Serialised as a real key, not omitted when false-by-accident: the client
|
||
// distinguishes "absent" (old server) from an explicit answer.
|
||
var raw map[string]any
|
||
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
|
||
t.Fatalf("decode raw config: %v", err)
|
||
}
|
||
if _, ok := raw["local_worktree_supported"]; !ok {
|
||
t.Fatal("local_worktree_supported missing from the JSON body")
|
||
}
|
||
}
|
||
|
||
// Web/Desktop can run ahead of a manually deployed backend. Handlers that
|
||
// predate conversation_starters ignore the unknown JSON field and still answer 200,
|
||
// so clients must see an explicit declaration before sending either create or
|
||
// update writes that contain it.
|
||
func TestGetConfigDeclaresAgentConversationStartersSupport(t *testing.T) {
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if !cfg.AgentConversationStartersSupported {
|
||
t.Fatal("this build persists conversation_starters but does not advertise the contract")
|
||
}
|
||
var raw map[string]any
|
||
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
|
||
t.Fatalf("decode raw config: %v", err)
|
||
}
|
||
if _, ok := raw["agent_conversation_starters_supported"]; !ok {
|
||
t.Fatal("agent_conversation_starters_supported missing from the JSON body")
|
||
}
|
||
}
|
||
|
||
func TestGetConfigDeclaresIssueCreatePropertiesSupport(t *testing.T) {
|
||
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 AppConfig
|
||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||
t.Fatalf("decode config: %v", err)
|
||
}
|
||
if !cfg.IssueCreatePropertiesSupported {
|
||
t.Fatal("this build atomically persists issue create properties but does not advertise the contract")
|
||
}
|
||
var raw map[string]any
|
||
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
|
||
t.Fatalf("decode raw config: %v", err)
|
||
}
|
||
if _, ok := raw["issue_create_properties_supported"]; !ok {
|
||
t.Fatal("issue_create_properties_supported missing from the JSON body")
|
||
}
|
||
}
|
||
|
||
// Web/Desktop, mobile and the CLI promise that a delete keeps the replies only
|
||
// when the server declares it (#8296). Older servers omit the field and delete
|
||
// the replies too, so this build must advertise the contract explicitly.
|
||
func TestGetConfigDeclaresCommentDeleteKeepsReplies(t *testing.T) {
|
||
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 raw map[string]any
|
||
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
|
||
t.Fatalf("decode raw config: %v", err)
|
||
}
|
||
if raw["comment_delete_keep_replies_supported"] != true {
|
||
t.Fatalf("comment_delete_keep_replies_supported = %v, want true", raw["comment_delete_keep_replies_supported"])
|
||
}
|
||
}
|