From 986ba72dc19f7b3c7d3dba4a1d29fa4c87c6cbf5 Mon Sep 17 00:00:00 2001 From: Klesh Wong Date: Sat, 16 May 2026 21:40:43 +0800 Subject: [PATCH 1/2] fix: harden dbt pipeline inputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/helpers/oidchelper/config.go | 24 ++-- backend/helpers/oidchelper/config_test.go | 47 +++++++ backend/plugins/dbt/dbt.go | 1 - backend/plugins/dbt/impl/impl.go | 18 ++- backend/plugins/dbt/tasks/convertor.go | 1 + backend/plugins/dbt/tasks/git.go | 24 ++-- backend/plugins/dbt/tasks/options.go | 132 ++++++++++++++++++ backend/plugins/dbt/tasks/options_test.go | 110 +++++++++++++++ backend/plugins/dbt/tasks/task_data.go | 3 + backend/server/api/auth/auth.go | 16 ++- backend/server/api/auth/middleware.go | 8 +- .../components/advanced-editor/example/dbt.ts | 2 +- env.example | 10 +- 13 files changed, 361 insertions(+), 35 deletions(-) create mode 100644 backend/plugins/dbt/tasks/options.go create mode 100644 backend/plugins/dbt/tasks/options_test.go diff --git a/backend/helpers/oidchelper/config.go b/backend/helpers/oidchelper/config.go index 4b45dfa3b43..477e80591de 100644 --- a/backend/helpers/oidchelper/config.go +++ b/backend/helpers/oidchelper/config.go @@ -88,20 +88,28 @@ func (c *Config) ProviderNames() []string { } // LoadConfig reads auth env vars via Viper and validates required fields. -// Returns Config{AuthEnabled:false} when AUTH_ENABLED=false (the default, -// preserves historical behavior). +// AUTH_ENABLED defaults to true unless it is explicitly set to false. func LoadConfig(basicRes context.BasicRes) (*Config, error) { cfg := basicRes.GetConfigReader() - if !cfg.GetBool("AUTH_ENABLED") { + authEnabled := true + if cfg.IsSet("AUTH_ENABLED") { + authEnabled = cfg.GetBool("AUTH_ENABLED") + } + if !authEnabled { return &Config{AuthEnabled: false}, nil } + oidcEnabled := cfg.GetBool("OIDC_ENABLED") sessionSecret := strings.TrimSpace(cfg.GetString("SESSION_SECRET")) - if sessionSecret == "" { - return nil, fmt.Errorf("AUTH_ENABLED=true but SESSION_SECRET is not set") - } - if len(sessionSecret) < 32 { + if oidcEnabled { + if sessionSecret == "" { + return nil, fmt.Errorf("OIDC_ENABLED=true but SESSION_SECRET is not set") + } + if len(sessionSecret) < 32 { + return nil, fmt.Errorf("SESSION_SECRET must be at least 32 bytes") + } + } else if sessionSecret != "" && len(sessionSecret) < 32 { return nil, fmt.Errorf("SESSION_SECRET must be at least 32 bytes") } @@ -121,7 +129,7 @@ func LoadConfig(basicRes context.BasicRes) (*Config, error) { out := &Config{ AuthEnabled: true, - OIDCEnabled: cfg.GetBool("OIDC_ENABLED"), + OIDCEnabled: oidcEnabled, Providers: map[string]*ProviderConfig{}, LogoutRedirect: cfg.GetBool("OIDC_LOGOUT_REDIRECT"), SessionSecret: []byte(sessionSecret), diff --git a/backend/helpers/oidchelper/config_test.go b/backend/helpers/oidchelper/config_test.go index 1924fbb5397..cc1d75f2cde 100644 --- a/backend/helpers/oidchelper/config_test.go +++ b/backend/helpers/oidchelper/config_test.go @@ -20,6 +20,13 @@ package oidchelper import ( "reflect" "testing" + + "github.com/spf13/viper" + + "github.com/apache/incubator-devlake/core/config" + corectx "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/log" ) func TestParseScopes(t *testing.T) { @@ -84,3 +91,43 @@ func TestProviderNamesSorted(t *testing.T) { t.Errorf("ProviderNames = %v, want sorted [entra google]", names) } } + +type basicResStub struct { + cfg config.ConfigReader +} + +func (b basicResStub) GetConfigReader() config.ConfigReader { return b.cfg } +func (b basicResStub) GetConfig(string) string { return "" } +func (b basicResStub) GetLogger() log.Logger { return nil } +func (b basicResStub) NestedLogger(string) corectx.BasicRes { return nil } +func (b basicResStub) ReplaceLogger(log.Logger) corectx.BasicRes { + return nil +} +func (b basicResStub) GetDal() dal.Dal { return nil } + +func TestLoadConfigDefaultsAuthEnabled(t *testing.T) { + v := viper.New() + + cfg, err := LoadConfig(basicResStub{cfg: v}) + if err != nil { + t.Fatalf("LoadConfig returned error: %v", err) + } + if !cfg.AuthEnabled { + t.Fatal("AuthEnabled should default to true when AUTH_ENABLED is unset") + } + if cfg.OIDCEnabled { + t.Fatal("OIDCEnabled should default to false") + } + if len(cfg.SessionSecret) != 0 { + t.Fatalf("SessionSecret = %q, want empty when OIDC is disabled", string(cfg.SessionSecret)) + } +} + +func TestLoadConfigRequiresSessionSecretForOIDC(t *testing.T) { + v := viper.New() + v.Set("OIDC_ENABLED", true) + + if _, err := LoadConfig(basicResStub{cfg: v}); err == nil { + t.Fatal("LoadConfig should reject OIDC-enabled config without SESSION_SECRET") + } +} diff --git a/backend/plugins/dbt/dbt.go b/backend/plugins/dbt/dbt.go index ffb435ddc4d..02d055a9d8b 100644 --- a/backend/plugins/dbt/dbt.go +++ b/backend/plugins/dbt/dbt.go @@ -28,7 +28,6 @@ var PluginEntry impl.Dbt // standalone mode for debugging func main() { dbtCmd := &cobra.Command{Use: "dbt"} - _ = dbtCmd.MarkFlagRequired("projectPath") projectPath := dbtCmd.Flags().StringP("projectPath", "p", "/Users/abeizn/demoapp", "user dbt project directory.") projectGitURL := dbtCmd.Flags().StringP("projectGitURL", "g", "", "user dbt project git url.") projectName := dbtCmd.Flags().StringP("projectName", "n", "demoapp", "user dbt project name.") diff --git a/backend/plugins/dbt/impl/impl.go b/backend/plugins/dbt/impl/impl.go index 88eafa9f633..5da8799fd87 100644 --- a/backend/plugins/dbt/impl/impl.go +++ b/backend/plugins/dbt/impl/impl.go @@ -18,6 +18,8 @@ limitations under the License. package impl import ( + "fmt" + "github.com/apache/incubator-devlake/core/dal" "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/plugin" @@ -28,6 +30,7 @@ import ( var _ interface { plugin.PluginMeta plugin.PluginTask + plugin.CloseablePluginTask plugin.PluginModel } = (*Dbt)(nil) @@ -54,8 +57,8 @@ func (p Dbt) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]inte if err != nil { return nil, err } - if op.ProjectPath == "" { - return nil, errors.Default.New("projectPath is required for dbt plugin") + if err := tasks.PrepareOptions(&op, taskCtx.GetConfig(tasks.DbtProjectBaseDirConfigKey)); err != nil { + return nil, err } if op.ProjectTarget == "" { @@ -71,6 +74,17 @@ func (p Dbt) RootPkgPath() string { return "github.com/apache/incubator-devlake/plugins/dbt" } +func (p Dbt) Close(taskCtx plugin.TaskContext) errors.Error { + data, ok := taskCtx.GetData().(*tasks.DbtTaskData) + if !ok || data == nil || data.Options == nil || !data.Options.ManagedProjectDir { + return nil + } + if err := tasks.CleanupManagedProjectDir(data.Options); err != nil { + return errors.Default.Wrap(err, fmt.Sprintf("cleanup dbt project path %q", data.Options.ProjectPath)) + } + return nil +} + func (p Dbt) Name() string { return "dbt" } diff --git a/backend/plugins/dbt/tasks/convertor.go b/backend/plugins/dbt/tasks/convertor.go index 3d4ac56d385..a3a2d3f5611 100644 --- a/backend/plugins/dbt/tasks/convertor.go +++ b/backend/plugins/dbt/tasks/convertor.go @@ -234,4 +234,5 @@ var DbtConverterMeta = plugin.SubTaskMeta{ EntryPoint: DbtConverter, EnabledByDefault: true, Description: "Convert data by dbt", + Dependencies: []*plugin.SubTaskMeta{&GitMeta}, } diff --git a/backend/plugins/dbt/tasks/git.go b/backend/plugins/dbt/tasks/git.go index 49ea81f9098..b43f3efcf9d 100644 --- a/backend/plugins/dbt/tasks/git.go +++ b/backend/plugins/dbt/tasks/git.go @@ -32,20 +32,26 @@ func Git(taskCtx plugin.SubTaskContext) errors.Error { return nil } - // clean ProjectPath - err := os.RemoveAll(data.Options.ProjectPath) + projectBaseDir, err := ensureProjectBaseDir(data.Options.ProjectBaseDir) if err != nil { - logger.Error(err, "cleanup before clone dbt project failed") - return errors.Convert(err) + logger.Error(err, "prepare dbt workspace failed") + return err } + projectPath, mkErr := os.MkdirTemp(projectBaseDir, "project-*") + if mkErr != nil { + logger.Error(mkErr, "create managed dbt project directory failed") + return errors.Convert(mkErr) + } + data.Options.ProjectPath = projectPath - // git clone from ProjectGitURL into ProjectPath + // git clone from ProjectGitURL into a managed temporary project directory cmd := exec.Command("git", "clone", data.Options.ProjectGitURL, data.Options.ProjectPath) logger.Info("start clone dbt project: %v", cmd) - out, err := cmd.CombinedOutput() - if err != nil { - logger.Error(err, "clone dbt project failed") - return errors.Convert(err) + out, cloneErr := cmd.CombinedOutput() + if cloneErr != nil { + _ = os.RemoveAll(data.Options.ProjectPath) + logger.Error(cloneErr, "clone dbt project failed") + return errors.Convert(cloneErr) } logger.Info("clone dbt project success: %v", string(out)) return nil diff --git a/backend/plugins/dbt/tasks/options.go b/backend/plugins/dbt/tasks/options.go new file mode 100644 index 00000000000..496290d4318 --- /dev/null +++ b/backend/plugins/dbt/tasks/options.go @@ -0,0 +1,132 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/apache/incubator-devlake/core/errors" +) + +const ( + DbtProjectBaseDirConfigKey = "DBT_PROJECTS_DIR" + dbtProjectBaseDirName = "devlake-dbt-projects" +) + +func PrepareOptions(op *DbtOptions, configuredBaseDir string) errors.Error { + if op == nil { + return errors.Default.New("dbt options are required") + } + + baseDir, err := normalizeBaseDir(configuredBaseDir) + if err != nil { + return err + } + op.ProjectBaseDir = baseDir + op.ProjectPath = strings.TrimSpace(op.ProjectPath) + op.ProjectGitURL = strings.TrimSpace(op.ProjectGitURL) + + if op.ProjectGitURL != "" { + if err := validateProjectGitURL(op.ProjectGitURL); err != nil { + return err + } + op.ProjectPath = "" + op.ManagedProjectDir = true + return nil + } + + if op.ProjectPath == "" { + return errors.Default.New("projectPath is required for local dbt projects") + } + + projectPath, err := normalizePathWithinBase(baseDir, op.ProjectPath) + if err != nil { + return err + } + op.ProjectPath = projectPath + return nil +} + +func normalizeBaseDir(configuredBaseDir string) (string, errors.Error) { + baseDir := strings.TrimSpace(configuredBaseDir) + if baseDir == "" { + baseDir = filepath.Join(os.TempDir(), dbtProjectBaseDirName) + } + baseDir, err := filepath.Abs(filepath.Clean(baseDir)) + if err != nil { + return "", errors.Convert(err) + } + return baseDir, nil +} + +func normalizePathWithinBase(baseDir string, candidate string) (string, errors.Error) { + normalizedPath, err := filepath.Abs(filepath.Clean(candidate)) + if err != nil { + return "", errors.Convert(err) + } + rel, err := filepath.Rel(baseDir, normalizedPath) + if err != nil { + return "", errors.Convert(err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", errors.Default.New("projectPath must stay within " + baseDir) + } + return normalizedPath, nil +} + +func validateProjectGitURL(rawURL string) errors.Error { + u, err := url.Parse(rawURL) + if err != nil { + return errors.Convert(err) + } + if u.Scheme != "https" && u.Scheme != "ssh" { + return errors.Default.New("projectGitURL must use https:// or ssh://") + } + if u.Host == "" { + return errors.Default.New("projectGitURL must include a hostname") + } + if u.Path == "" || u.Path == "/" { + return errors.Default.New("projectGitURL must include a repository path") + } + return nil +} + +func ensureProjectBaseDir(baseDir string) (string, errors.Error) { + baseDir, err := normalizeBaseDir(baseDir) + if err != nil { + return "", err + } + if err := os.MkdirAll(baseDir, 0o755); err != nil { + return "", errors.Convert(err) + } + return baseDir, nil +} + +func CleanupManagedProjectDir(op *DbtOptions) errors.Error { + if op == nil || !op.ManagedProjectDir || op.ProjectPath == "" { + return nil + } + projectPath, err := normalizePathWithinBase(op.ProjectBaseDir, op.ProjectPath) + if err != nil { + return err + } + return errors.Convert(os.RemoveAll(projectPath)) +} diff --git a/backend/plugins/dbt/tasks/options_test.go b/backend/plugins/dbt/tasks/options_test.go new file mode 100644 index 00000000000..740e3527306 --- /dev/null +++ b/backend/plugins/dbt/tasks/options_test.go @@ -0,0 +1,110 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPrepareOptionsNormalizesLocalProjectPath(t *testing.T) { + baseDir := t.TempDir() + projectDir := filepath.Join(baseDir, "nested", "..", "project") + + op := &DbtOptions{ProjectPath: projectDir} + if err := PrepareOptions(op, baseDir); err != nil { + t.Fatalf("PrepareOptions returned error: %v", err) + } + + want := filepath.Join(baseDir, "project") + if op.ProjectPath != want { + t.Fatalf("ProjectPath = %q, want %q", op.ProjectPath, want) + } + if op.ProjectBaseDir != baseDir { + t.Fatalf("ProjectBaseDir = %q, want %q", op.ProjectBaseDir, baseDir) + } + if op.ManagedProjectDir { + t.Fatal("ManagedProjectDir should be false for local projects") + } +} + +func TestPrepareOptionsRejectsPathTraversal(t *testing.T) { + baseDir := t.TempDir() + op := &DbtOptions{ProjectPath: filepath.Join(baseDir, "..", "outside")} + + if err := PrepareOptions(op, baseDir); err == nil { + t.Fatal("PrepareOptions should reject projectPath outside the configured base directory") + } +} + +func TestPrepareOptionsRejectsUnsafeGitURLs(t *testing.T) { + baseDir := t.TempDir() + cases := []string{ + "file:///tmp/evil", + "../relative/repo.git", + "git@github.com:apache/incubator-devlake.git", + } + + for _, rawURL := range cases { + t.Run(rawURL, func(t *testing.T) { + op := &DbtOptions{ProjectGitURL: rawURL} + if err := PrepareOptions(op, baseDir); err == nil { + t.Fatalf("PrepareOptions should reject %q", rawURL) + } + }) + } +} + +func TestPrepareOptionsAllowsManagedGitClone(t *testing.T) { + baseDir := t.TempDir() + op := &DbtOptions{ + ProjectPath: filepath.Join(baseDir, "ignored"), + ProjectGitURL: "https://github.com/apache/incubator-devlake.git", + } + + if err := PrepareOptions(op, baseDir); err != nil { + t.Fatalf("PrepareOptions returned error: %v", err) + } + if !op.ManagedProjectDir { + t.Fatal("ManagedProjectDir should be true for git-backed projects") + } + if op.ProjectPath != "" { + t.Fatalf("ProjectPath = %q, want empty for managed git clones", op.ProjectPath) + } +} + +func TestCleanupManagedProjectDirRemovesManagedPath(t *testing.T) { + baseDir := t.TempDir() + projectDir := filepath.Join(baseDir, "clone") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + + op := &DbtOptions{ + ProjectBaseDir: baseDir, + ProjectPath: projectDir, + ManagedProjectDir: true, + } + if err := CleanupManagedProjectDir(op); err != nil { + t.Fatalf("CleanupManagedProjectDir returned error: %v", err) + } + if _, err := os.Stat(projectDir); !os.IsNotExist(err) { + t.Fatalf("expected %q to be removed, stat err = %v", projectDir, err) + } +} diff --git a/backend/plugins/dbt/tasks/task_data.go b/backend/plugins/dbt/tasks/task_data.go index d741954a9f1..00555fa2b90 100644 --- a/backend/plugins/dbt/tasks/task_data.go +++ b/backend/plugins/dbt/tasks/task_data.go @@ -39,6 +39,9 @@ type DbtOptions struct { // deprecated, dbt run args Args []string `json:"args"` Tasks []string `json:"tasks,omitempty"` + + ProjectBaseDir string `json:"-"` + ManagedProjectDir bool `json:"-"` } type DbtTaskData struct { diff --git a/backend/server/api/auth/auth.go b/backend/server/api/auth/auth.go index e1012083ee8..ea7029a3824 100644 --- a/backend/server/api/auth/auth.go +++ b/backend/server/api/auth/auth.go @@ -84,7 +84,7 @@ var ( ) // Init builds the default Service from env config and starts the background -// loops. Panics if AUTH_ENABLED=true but the config is incomplete. +// loops. Panics if auth is enabled but the config is incomplete. func Init(basicRes corectx.BasicRes) { initOnce.Do(func() { s, err := NewService(stdctx.Background(), basicRes) @@ -357,13 +357,15 @@ func (s *Service) Logout(c *gin.Context) { } var sessionProvider string - if raw, err := c.Cookie(oidchelper.SessionCookieName); err == nil && raw != "" { - if claims, err := oidchelper.ParseSession(s.cfg.SessionSecret, raw); err == nil && claims.ID != "" { - sessionProvider = claims.Provider - if err := RevokeSession(s.db, claims.ID); err != nil { - s.logger.Error(err, "auth: revoke session row") + if len(s.cfg.SessionSecret) > 0 { + if raw, err := c.Cookie(oidchelper.SessionCookieName); err == nil && raw != "" { + if claims, err := oidchelper.ParseSession(s.cfg.SessionSecret, raw); err == nil && claims.ID != "" { + sessionProvider = claims.Provider + if err := RevokeSession(s.db, claims.ID); err != nil { + s.logger.Error(err, "auth: revoke session row") + } + s.revoked.Add(claims.ID) } - s.revoked.Add(claims.ID) } } oidchelper.ClearSessionCookie(c, s.cfg) diff --git a/backend/server/api/auth/middleware.go b/backend/server/api/auth/middleware.go index ef39cabf72d..231f4d5460c 100644 --- a/backend/server/api/auth/middleware.go +++ b/backend/server/api/auth/middleware.go @@ -65,6 +65,10 @@ func (s *Service) OIDCAuthentication() gin.HandlerFunc { c.Next() return } + if len(s.cfg.SessionSecret) == 0 { + c.Next() + return + } raw, err := c.Cookie(oidchelper.SessionCookieName) if err != nil || raw == "" { c.Next() @@ -92,8 +96,8 @@ func (s *Service) OIDCAuthentication() gin.HandlerFunc { } } -// RequireAuth is the terminal gate. No-op when AUTH_ENABLED=false so existing -// deployments are unaffected. +// RequireAuth is the terminal gate. It only becomes a no-op when +// AUTH_ENABLED=false is set explicitly. func (s *Service) RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { if s.cfg == nil || !s.cfg.AuthEnabled { diff --git a/config-ui/src/routes/blueprint/detail/components/advanced-editor/example/dbt.ts b/config-ui/src/routes/blueprint/detail/components/advanced-editor/example/dbt.ts index 6b3117e2df0..c09a3a22847 100644 --- a/config-ui/src/routes/blueprint/detail/components/advanced-editor/example/dbt.ts +++ b/config-ui/src/routes/blueprint/detail/components/advanced-editor/example/dbt.ts @@ -21,7 +21,7 @@ const dbt = [ { plugin: 'dbt', options: { - projectPath: '/var/www/html/my-project', + projectPath: '/tmp/devlake-dbt-projects/my-project', projectGitURL: '', projectName: 'myproject', projectTarget: 'dev', diff --git a/env.example b/env.example index 1cfd04a6b4c..64fa6b39fd6 100755 --- a/env.example +++ b/env.example @@ -93,10 +93,10 @@ ENABLE_SUBTASKS_BY_DEFAULT="jira:collectIssueChangelogs:true,jira:extractIssueCh ########################## # OIDC / Authentication ########################## -# Master switch. When false (default) DevLake behaves as before: API keys for -# /rest/* and trust X-Forwarded-User from an upstream proxy. Set true to -# require authentication on all non-whitelisted routes. -AUTH_ENABLED=false +# Master switch. Auth is enabled by default; set false only for isolated local +# development. When enabled without OIDC, DevLake accepts API keys for /rest/* +# and can trust X-Forwarded-User from an upstream proxy. +AUTH_ENABLED=true # OIDC user login. Requires AUTH_ENABLED=true. OIDC_ENABLED=false @@ -142,7 +142,7 @@ OIDC_GOOGLE_DISPLAY_NAME=Google # can also sign the user out at the IdP. OIDC_LOGOUT_REDIRECT=false -# Required when AUTH_ENABLED=true. At least 32 bytes of high-entropy data. +# Required when OIDC_ENABLED=true. At least 32 bytes of high-entropy data. # Used to sign session JWTs (HS256) and to derive the AES-GCM key that # encrypts the OIDC state cookie. Rotating this invalidates all sessions. SESSION_SECRET= From 0391cb054eb3f5af29a0ed090aa930f2a94a508f Mon Sep 17 00:00:00 2001 From: Klesh Wong Date: Sat, 16 May 2026 23:09:25 +0800 Subject: [PATCH 2/2] fix(test): disable auth for local e2e server Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/test/helper/client.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/test/helper/client.go b/backend/test/helper/client.go index 8c5c9d22aed..c3855f58956 100644 --- a/backend/test/helper/client.go +++ b/backend/test/helper/client.go @@ -137,6 +137,10 @@ func ConnectLocalServer(t *testing.T, clientConfig *LocalClientConfig) *DevlakeC fmt.Printf("Using test temp directory: %s\n", throwawayDir) logger := logruslog.Global.Nested("test") cfg := config.GetConfig() + // E2E helpers issue direct API requests without session or API-key auth. + // Keep local test servers aligned with that contract. + t.Setenv("AUTH_ENABLED", "false") + cfg.Set("AUTH_ENABLED", false) cfg.Set("DB_URL", clientConfig.DbURL) db, err := runner.NewGormDb(cfg, logger) require.NoError(t, err)