Files
ac673fbf19 MUL-7384: fix(onboarding): require confirmed daemon setup targets (#8907)
* 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]>
2026-10-27 13:19:05 +08:00

657 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"])
}
}