Files
kandev/apps/backend/internal/common/config/config_test.go
T
José Almeida 9c16d337a2 feat(web): add global app status surface (#1869)
* feat(web): add app status bar

* feat(web): add app status bar visibility toggle

* feat(web): persist status bar item order

* fix(web): refine app status bar behavior

* fix(web): polish app status bar presentation

* test(web): split plugin slot coverage groups

* fix(web): address status surface review feedback

* fix(web): stabilize status bar overlay tests
2026-07-23 12:28:54 +01:00

413 lines
13 KiB
Go

package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// minimalValidConfig returns a Config that passes validate() out of the box.
// Tests modify a copy to exercise individual validation branches.
func minimalValidConfig() *Config {
return &Config{
Server: ServerConfig{Port: 38429},
Database: DatabaseConfig{Driver: "sqlite"},
Auth: AuthConfig{TokenDuration: 3600},
Logging: LoggingConfig{Level: "info", Format: "text"},
RepositoryDiscovery: RepositoryDiscoveryConfig{
MaxDepth: 5,
},
}
}
func TestResolvedHomeDir_Default(t *testing.T) {
cfg := &Config{}
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot determine home directory")
}
want := filepath.Join(home, ".kandev")
if got := cfg.ResolvedHomeDir(); got != want {
t.Errorf("ResolvedHomeDir() = %q, want %q", got, want)
}
}
func TestResolvedHomeDir_WithHomeDir(t *testing.T) {
cfg := &Config{HomeDir: "/custom/kandev"}
if got := cfg.ResolvedHomeDir(); got != "/custom/kandev" {
t.Errorf("ResolvedHomeDir() = %q, want %q", got, "/custom/kandev")
}
}
func TestResolvedHomeDir_TildeExpansion(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot determine home directory")
}
cfg := &Config{HomeDir: "~/.kandev-dev"}
want := filepath.Join(home, ".kandev-dev")
if got := cfg.ResolvedHomeDir(); got != want {
t.Errorf("ResolvedHomeDir() = %q, want %q", got, want)
}
}
func TestResolvedDataDir_Default(t *testing.T) {
cfg := &Config{}
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot determine home directory")
}
want := filepath.Join(home, ".kandev", "data")
if got := cfg.ResolvedDataDir(); got != want {
t.Errorf("ResolvedDataDir() = %q, want %q", got, want)
}
}
func TestResolvedDataDir_DerivedFromHomeDir(t *testing.T) {
// Data always lives under <HomeDir>/data. No independent override.
cfg := &Config{HomeDir: "/custom/kandev"}
want := filepath.Join("/custom/kandev", "data")
if got := cfg.ResolvedDataDir(); got != want {
t.Errorf("ResolvedDataDir() = %q, want %q", got, want)
}
}
func TestValidate_DatabaseDriver(t *testing.T) {
t.Run("sqlite_ok", func(t *testing.T) {
cfg := minimalValidConfig()
if err := validate(cfg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("mixed_case_postgres_normalized", func(t *testing.T) {
cfg := minimalValidConfig()
cfg.Database.Driver = "Postgres"
cfg.Database.Port = 5432
cfg.Database.User = "u"
cfg.Database.DBName = "db"
cfg.Database.SSLMode = "disable"
if err := validate(cfg); err != nil {
t.Fatalf("expected mixed-case 'Postgres' to normalize, got %v", err)
}
if cfg.Database.Driver != "postgres" {
t.Errorf("driver not normalized: got %q, want %q", cfg.Database.Driver, "postgres")
}
})
t.Run("unknown_driver_rejected", func(t *testing.T) {
cfg := minimalValidConfig()
cfg.Database.Driver = "mysql"
err := validate(cfg)
if err == nil || !strings.Contains(err.Error(), "database.driver") {
t.Fatalf("expected database.driver error, got %v", err)
}
})
}
func TestValidate_PostgresSSLMode(t *testing.T) {
for _, mode := range []string{"disable", "require", "verify-ca", "verify-full"} {
t.Run(mode, func(t *testing.T) {
cfg := minimalValidConfig()
cfg.Database.Driver = "postgres"
cfg.Database.Port = 5432
cfg.Database.User = "u"
cfg.Database.DBName = "db"
cfg.Database.SSLMode = mode
if err := validate(cfg); err != nil && strings.Contains(err.Error(), "sslMode") {
t.Errorf("sslMode %q rejected unexpectedly: %v", mode, err)
}
})
}
t.Run("invalid_rejected", func(t *testing.T) {
cfg := minimalValidConfig()
cfg.Database.Driver = "postgres"
cfg.Database.Port = 5432
cfg.Database.User = "u"
cfg.Database.DBName = "db"
cfg.Database.SSLMode = "bogus"
err := validate(cfg)
if err == nil || !strings.Contains(err.Error(), "sslMode") {
t.Fatalf("expected sslMode error, got %v", err)
}
})
t.Run("sqlite_ignores_sslmode", func(t *testing.T) {
cfg := minimalValidConfig()
cfg.Database.SSLMode = "bogus"
if err := validate(cfg); err != nil {
t.Errorf("sqlite should ignore sslMode, got %v", err)
}
})
}
// TestFeatures_ProductionDefaults pins the production policy: in-progress
// features remain off, while the shipped app status bar remains on unless a
// deployment or saved runtime override explicitly disables it.
func TestFeatures_ProductionDefaults(t *testing.T) {
// Force a clean env so KANDEV_FEATURES_* and profile-selector vars from the
// host shell cannot change the production-profile defaults under test.
t.Setenv("KANDEV_FEATURES_OFFICE", "")
t.Setenv("KANDEV_FEATURES_PLUGINS", "")
unsetEnv(t, "KANDEV_FEATURES_APP_STATUS_BAR")
t.Setenv("KANDEV_DEBUG_DEV_MODE", "")
t.Setenv("KANDEV_DEBUG_PPROF_ENABLED", "")
t.Setenv("KANDEV_E2E_MOCK", "")
dir := t.TempDir()
cfg, err := LoadWithPath(dir)
if err != nil {
t.Fatalf("LoadWithPath: %v", err)
}
if cfg.Features.Office {
t.Errorf("Features.Office = true, want false (production default must be off)")
}
if cfg.Features.Plugins {
t.Errorf("Features.Plugins = true, want false (production default must be off)")
}
if cfg.Features.AppStatusBar {
t.Error("Features.AppStatusBar = true, want false (status surface must remain opt-in by default)")
}
}
// TestFeatures_OfficeEnabledByEnv proves the documented opt-in path:
// setting KANDEV_FEATURES_OFFICE=true flips Features.Office to true. This
// is what `apps/cli/src/dev.ts` relies on for dev mode and what release
// deployments would set if they ever wanted Office on.
func TestFeatures_OfficeEnabledByEnv(t *testing.T) {
t.Setenv("KANDEV_FEATURES_OFFICE", "true")
dir := t.TempDir()
cfg, err := LoadWithPath(dir)
if err != nil {
t.Fatalf("LoadWithPath: %v", err)
}
if !cfg.Features.Office {
t.Errorf("Features.Office = false, want true (KANDEV_FEATURES_OFFICE=true must flip the flag)")
}
}
// TestFeatures_PluginsEnabledByEnv proves the documented opt-in path:
// setting KANDEV_FEATURES_PLUGINS=true flips Features.Plugins to true.
func TestFeatures_PluginsEnabledByEnv(t *testing.T) {
t.Setenv("KANDEV_FEATURES_PLUGINS", "true")
dir := t.TempDir()
cfg, err := LoadWithPath(dir)
if err != nil {
t.Fatalf("LoadWithPath: %v", err)
}
if !cfg.Features.Plugins {
t.Errorf("Features.Plugins = false, want true (KANDEV_FEATURES_PLUGINS=true must flip the flag)")
}
}
func TestFeatures_AppStatusBarDisabledByEnv(t *testing.T) {
t.Setenv("KANDEV_FEATURES_APP_STATUS_BAR", "false")
cfg, err := LoadWithPath(t.TempDir())
if err != nil {
t.Fatalf("LoadWithPath: %v", err)
}
if cfg.Features.AppStatusBar {
t.Error("Features.AppStatusBar = true, want false (KANDEV_FEATURES_APP_STATUS_BAR=false must hide it)")
}
}
func unsetEnv(t *testing.T, name string) {
t.Helper()
value, set := os.LookupEnv(name)
_ = os.Unsetenv(name)
t.Cleanup(func() {
if set {
_ = os.Setenv(name, value)
return
}
_ = os.Unsetenv(name)
})
}
func TestServerHostFromEnv(t *testing.T) {
t.Setenv("KANDEV_SERVER_HOST", "127.0.0.1")
cfg, err := LoadWithPath(t.TempDir())
if err != nil {
t.Fatalf("LoadWithPath: %v", err)
}
if cfg.Server.Host != "127.0.0.1" {
t.Fatalf("Server.Host = %q, want 127.0.0.1", cfg.Server.Host)
}
}
func TestResolvedBinds(t *testing.T) {
tests := []struct {
name string
host string
hosts []string
want []string
wantErr bool
}{
{name: "single host", host: "127.0.0.1", want: []string{"127.0.0.1"}},
{name: "empty falls back to wildcard default", host: "", want: []string{"0.0.0.0"}},
{name: "comma separated list", host: "127.0.0.1,100.64.0.1", want: []string{"127.0.0.1", "100.64.0.1"}},
{name: "whitespace trimmed", host: " 127.0.0.1 , 100.64.0.1 ", want: []string{"127.0.0.1", "100.64.0.1"}},
{name: "duplicates dropped", host: "127.0.0.1,127.0.0.1,::1", want: []string{"127.0.0.1", "::1"}},
{name: "wildcard collapses set", host: "127.0.0.1,0.0.0.0", want: []string{"0.0.0.0"}},
{name: "ipv6 wildcard collapses set", host: "::,127.0.0.1", want: []string{"::"}},
{name: "hostname allowed", host: "my-tailnet-host", want: []string{"my-tailnet-host"}},
{name: "hosts array used when host unset", host: "", hosts: []string{"127.0.0.1", "100.64.0.1"}, want: []string{"127.0.0.1", "100.64.0.1"}},
{name: "explicit host wins over hosts array", host: "127.0.0.1", hosts: []string{"0.0.0.0"}, want: []string{"127.0.0.1"}},
{name: "hosts array as comma string", hosts: []string{"127.0.0.1,100.64.0.1"}, want: []string{"127.0.0.1", "100.64.0.1"}},
{name: "equivalent ipv6 forms dedupe", host: "::1,0:0:0:0:0:0:0:1", want: []string{"::1"}},
{name: "longhand unspecified ipv6 is wildcard", host: "0:0:0:0:0:0:0:0,127.0.0.1", want: []string{"::"}},
{name: "invalid entry errors", host: "127.0.0.1,not a host!!", wantErr: true},
{name: "invalid entry after wildcard still errors", host: "0.0.0.0,not a host!!", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sc := ServerConfig{Host: tt.host, Hosts: tt.hosts}
got, err := sc.ResolvedBinds()
if tt.wantErr {
if err == nil {
t.Fatalf("ResolvedBinds() expected error, got %v", got)
}
if !strings.Contains(err.Error(), "not a host!!") {
t.Fatalf("ResolvedBinds() error = %q, want it to name the bad entry", err)
}
return
}
if err != nil {
t.Fatalf("ResolvedBinds() unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("ResolvedBinds() = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("ResolvedBinds() = %v, want %v", got, tt.want)
}
}
})
}
}
func TestIsLoopbackHost(t *testing.T) {
tests := []struct {
host string
want bool
}{
{"127.0.0.1", true},
{"127.0.0.53", true},
{"::1", true},
{"localhost", true},
{"LocalHost", true},
{"0.0.0.0", false},
{"::", false},
{"", false},
{"100.64.0.1", false},
{"my-tailnet-host", false},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
if got := IsLoopbackHost(tt.host); got != tt.want {
t.Fatalf("IsLoopbackHost(%q) = %v, want %v", tt.host, got, tt.want)
}
})
}
}
func TestNonLoopbackBinds(t *testing.T) {
sc := ServerConfig{Host: "127.0.0.1,100.64.0.1,::1"}
got, err := sc.NonLoopbackBinds()
if err != nil {
t.Fatalf("NonLoopbackBinds() error: %v", err)
}
if len(got) != 1 || got[0] != "100.64.0.1" {
t.Fatalf("NonLoopbackBinds() = %v, want [100.64.0.1]", got)
}
loopOnly := ServerConfig{Host: "127.0.0.1"}
if got, err := loopOnly.NonLoopbackBinds(); err != nil || len(got) != 0 {
t.Fatalf("NonLoopbackBinds() = %v (err %v), want empty", got, err)
}
wildcard := ServerConfig{Host: "0.0.0.0"}
if got, err := wildcard.NonLoopbackBinds(); err != nil || len(got) != 1 || got[0] != "0.0.0.0" {
t.Fatalf("NonLoopbackBinds() = %v (err %v), want [0.0.0.0]", got, err)
}
}
// TestServerHostEnvOverridesConfigHosts verifies env-over-file precedence: a
// KANDEV_SERVER_HOST env override must win over a config-file server.hosts
// array, so launching with a loopback host binds only loopback even if the
// config file left non-loopback addresses in server.hosts. Regression for the
// desktop/headless loopback contract.
func TestServerHostEnvOverridesConfigHosts(t *testing.T) {
dir := t.TempDir()
cfgYAML := "server:\n hosts:\n - 0.0.0.0\n - 100.64.0.1\n"
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(cfgYAML), 0o600); err != nil {
t.Fatalf("write config.yaml: %v", err)
}
t.Setenv("KANDEV_SERVER_HOST", "127.0.0.1")
cfg, err := LoadWithPath(dir)
if err != nil {
t.Fatalf("LoadWithPath: %v", err)
}
binds, err := cfg.Server.ResolvedBinds()
if err != nil {
t.Fatalf("ResolvedBinds: %v", err)
}
if len(binds) != 1 || binds[0] != "127.0.0.1" {
t.Fatalf("ResolvedBinds() = %v, want [127.0.0.1] (env host must override config server.hosts)", binds)
}
}
// TestServerHostsFromConfigWhenHostUnset confirms server.hosts is honored when
// no host/env override is present.
func TestServerHostsFromConfigWhenHostUnset(t *testing.T) {
dir := t.TempDir()
cfgYAML := "server:\n hosts:\n - 127.0.0.1\n - 100.64.0.1\n"
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(cfgYAML), 0o600); err != nil {
t.Fatalf("write config.yaml: %v", err)
}
// Ensure no ambient KANDEV_SERVER_HOST override leaks into the test.
t.Setenv("KANDEV_SERVER_HOST", "")
cfg, err := LoadWithPath(dir)
if err != nil {
t.Fatalf("LoadWithPath: %v", err)
}
binds, err := cfg.Server.ResolvedBinds()
if err != nil {
t.Fatalf("ResolvedBinds: %v", err)
}
want := []string{"127.0.0.1", "100.64.0.1"}
if len(binds) != len(want) || binds[0] != want[0] || binds[1] != want[1] {
t.Fatalf("ResolvedBinds() = %v, want %v", binds, want)
}
}
// TestFeaturesConfig_JSONShape pins the wire format of GET /api/v1/features.
// The handler in helpers.go serializes FeaturesConfig directly so new
// fields flow through without an extra edit; this test guarantees the
// `json` tag is present on every field. A regression (struct field added
// without a tag) would surface as a capitalized JSON key and break the
// frontend's case-sensitive read in apps/web/app/actions/features.ts.
func TestFeaturesConfig_JSONShape(t *testing.T) {
cfg := FeaturesConfig{Office: true, Plugins: true, AppStatusBar: true}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
got := string(raw)
want := `{"office":true,"plugins":true,"appStatusBar":true}`
if got != want {
t.Errorf("FeaturesConfig JSON = %s; want %s — missing or wrong `json:` struct tag", got, want)
}
}