f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
1878 lines
51 KiB
Go
1878 lines
51 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"flag"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
"github.com/spf13/pflag"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
)
|
|
|
|
const configFileName = "config.toml"
|
|
|
|
func skipIfNotUnix(t *testing.T) {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip(
|
|
"skipping: Unix permissions not reliable on Windows",
|
|
)
|
|
}
|
|
if os.Getuid() == 0 {
|
|
t.Skip(
|
|
"skipping: running as root bypasses permissions",
|
|
)
|
|
}
|
|
}
|
|
|
|
func writeConfig(t *testing.T, dir string, data any) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
require.NoError(t, toml.NewEncoder(&buf).Encode(data), "marshal config")
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, configFileName), buf.Bytes(), 0o600), "write config")
|
|
}
|
|
|
|
func setupTestEnv(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
|
|
t.Setenv("AGENTSVIEW_DATA_DIR", dir)
|
|
return dir
|
|
}
|
|
|
|
type configFixture struct {
|
|
Dir string
|
|
}
|
|
|
|
func newConfigFixture(t *testing.T) configFixture {
|
|
t.Helper()
|
|
return configFixture{Dir: setupTestEnv(t)}
|
|
}
|
|
|
|
func (f configFixture) Path(name string) string {
|
|
return filepath.Join(f.Dir, name)
|
|
}
|
|
|
|
func (f configFixture) WriteTOML(t *testing.T, data any) {
|
|
t.Helper()
|
|
writeConfig(t, f.Dir, data)
|
|
}
|
|
|
|
func (f configFixture) WriteConfigText(t *testing.T, text string) {
|
|
t.Helper()
|
|
require.NoError(t, os.WriteFile(f.Path(configFileName), []byte(text), 0o600),
|
|
"write config")
|
|
}
|
|
|
|
func (f configFixture) WriteLegacyJSON(t *testing.T, text string) {
|
|
t.Helper()
|
|
require.NoError(t, os.WriteFile(f.Path("config.json"), []byte(text), 0o600),
|
|
"write legacy config")
|
|
}
|
|
|
|
func (f configFixture) LoadMinimal(t *testing.T) Config {
|
|
t.Helper()
|
|
cfg, err := LoadMinimal()
|
|
require.NoError(t, err)
|
|
return cfg
|
|
}
|
|
|
|
func (f configFixture) LoadMinimalErr(t *testing.T) error {
|
|
t.Helper()
|
|
_, err := LoadMinimal()
|
|
return err
|
|
}
|
|
|
|
func (f configFixture) LoadFile(t *testing.T) Config {
|
|
t.Helper()
|
|
cfg, err := Default()
|
|
require.NoError(t, err)
|
|
cfg.DataDir = f.Dir
|
|
require.NoError(t, cfg.loadFile(), "loadFile")
|
|
return cfg
|
|
}
|
|
|
|
func (f configFixture) ReadTOMLMap(t *testing.T) map[string]any {
|
|
t.Helper()
|
|
got, err := os.ReadFile(f.Path(configFileName))
|
|
require.NoError(t, err)
|
|
var result map[string]any
|
|
_, err = toml.Decode(string(got), &result)
|
|
require.NoError(t, err)
|
|
return result
|
|
}
|
|
|
|
func loadMinimalWithConfig(t *testing.T, data any) Config {
|
|
t.Helper()
|
|
f := newConfigFixture(t)
|
|
f.WriteTOML(t, data)
|
|
return f.LoadMinimal(t)
|
|
}
|
|
|
|
func loadMinimalErrWithConfig(t *testing.T, data any) error {
|
|
t.Helper()
|
|
f := newConfigFixture(t)
|
|
f.WriteTOML(t, data)
|
|
return f.LoadMinimalErr(t)
|
|
}
|
|
|
|
func loadPFlagsWithConfig(
|
|
t *testing.T,
|
|
data any,
|
|
args ...string,
|
|
) Config {
|
|
t.Helper()
|
|
f := newConfigFixture(t)
|
|
f.WriteTOML(t, data)
|
|
cfg, err := loadConfigFromPFlags(t, args...)
|
|
require.NoError(t, err, "loading config")
|
|
return cfg
|
|
}
|
|
|
|
func runConcurrent(t *testing.T, workers int, fn func(i int) error) {
|
|
t.Helper()
|
|
start := make(chan struct{})
|
|
errs := make(chan error, workers)
|
|
var wg sync.WaitGroup
|
|
wg.Add(workers)
|
|
for i := range workers {
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
if err := fn(i); err != nil {
|
|
errs <- err
|
|
}
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
func requireAllSameNonEmpty[T comparable](t *testing.T, values []T) {
|
|
t.Helper()
|
|
require.NotEmpty(t, values)
|
|
for _, value := range values {
|
|
require.NotZero(t, value)
|
|
assert.Equal(t, values[0], value)
|
|
}
|
|
}
|
|
|
|
func requireErrorContains(t *testing.T, err error, substrs ...string) {
|
|
t.Helper()
|
|
require.Error(t, err)
|
|
for _, substr := range substrs {
|
|
assert.Contains(t, err.Error(), substr)
|
|
}
|
|
}
|
|
|
|
func captureLog(t *testing.T) *bytes.Buffer {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
prev := log.Writer()
|
|
log.SetOutput(&buf)
|
|
t.Cleanup(func() { log.SetOutput(prev) })
|
|
return &buf
|
|
}
|
|
|
|
func assertLogContains(t *testing.T, buf *bytes.Buffer, substrs ...string) {
|
|
t.Helper()
|
|
logged := buf.String()
|
|
for _, substr := range substrs {
|
|
assert.Contains(t, logged, substr)
|
|
}
|
|
}
|
|
|
|
func loadConfigFromFlags(t *testing.T, args ...string) (Config, error) {
|
|
t.Helper()
|
|
if os.Getenv("AGENTSVIEW_DATA_DIR") == "" {
|
|
t.Setenv("AGENTSVIEW_DATA_DIR", t.TempDir())
|
|
}
|
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
RegisterServeFlags(fs)
|
|
if err := fs.Parse(args); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return Load(fs)
|
|
}
|
|
|
|
func loadConfigFromPFlags(t *testing.T, args ...string) (Config, error) {
|
|
t.Helper()
|
|
if os.Getenv("AGENTSVIEW_DATA_DIR") == "" {
|
|
t.Setenv("AGENTSVIEW_DATA_DIR", t.TempDir())
|
|
}
|
|
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
|
RegisterServePFlags(fs)
|
|
if err := fs.Parse(args); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return LoadPFlags(fs)
|
|
}
|
|
|
|
func TestLoadMinimal_LoadsAgentBinaryConfig(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteConfigText(t, `[agent.claude]
|
|
binary = "/opt/agents/claude"
|
|
|
|
[agent.gemini]
|
|
binary = "/usr/local/bin/gemini"
|
|
sandbox = "sandbox-exec"
|
|
allow_unsafe = true
|
|
`)
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
assert.Equal(t, "/opt/agents/claude", cfg.Agent["claude"].Binary)
|
|
assert.Equal(t, "/usr/local/bin/gemini", cfg.Agent["gemini"].Binary)
|
|
assert.Equal(t, "sandbox-exec", cfg.Agent["gemini"].Sandbox)
|
|
assert.True(t, cfg.Agent["gemini"].AllowUnsafe)
|
|
}
|
|
|
|
func TestLoadReadOnlyReadsLegacyJSONWithoutMigrating(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
jsonPath := f.Path("config.json")
|
|
f.WriteLegacyJSON(t, `{
|
|
"codex_sessions_dirs": ["/legacy/codex"],
|
|
"result_content_blocked_categories": ["Read", "Search"]
|
|
}`)
|
|
|
|
cfg, err := LoadReadOnly()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, []string{"/legacy/codex"},
|
|
cfg.ResolveDirs(parser.AgentCodex))
|
|
assert.Equal(t, []string{"Read", "Search"},
|
|
cfg.ResultContentBlockedCategories)
|
|
assert.FileExists(t, jsonPath)
|
|
assert.NoFileExists(t, f.Path(configFileName))
|
|
assert.NoFileExists(t, jsonPath+".bak")
|
|
}
|
|
|
|
func TestDefault_IncludesCodexArchivedSessionsDir(t *testing.T) {
|
|
cfg, err := Default()
|
|
require.NoError(t, err)
|
|
|
|
dirs := cfg.ResolveDirs(parser.AgentCodex)
|
|
require.Len(t, dirs, 2)
|
|
assert.True(t, strings.HasSuffix(dirs[0], filepath.Join(".codex", "sessions")), "dirs[0] = %q", dirs[0])
|
|
assert.True(t, strings.HasSuffix(dirs[1], filepath.Join(".codex", "archived_sessions")), "dirs[1] = %q", dirs[1])
|
|
}
|
|
|
|
func TestDefault_SkipsAiderUntilConfigured(t *testing.T) {
|
|
t.Setenv("AIDER_DIR", "")
|
|
cfg, err := Default()
|
|
require.NoError(t, err)
|
|
|
|
// Aider has no safe default root: a passive viewer must not enumerate
|
|
// $HOME (macOS privacy prompts), so it stays unresolved until the user
|
|
// opts in via AIDER_DIR or aider_dirs.
|
|
assert.Empty(t, cfg.ResolveDirs(parser.AgentAider))
|
|
assert.False(t, cfg.IsUserConfigured(parser.AgentAider))
|
|
}
|
|
|
|
func TestDefault_IncludesDevinLocalShareRoots(t *testing.T) {
|
|
cfg, err := Default()
|
|
require.NoError(t, err)
|
|
|
|
dirs := cfg.ResolveDirs(parser.AgentDevin)
|
|
require.Len(t, dirs, 2)
|
|
assert.True(t, strings.HasSuffix(dirs[0], filepath.Join("Library", "Application Support", "devin")), "dirs[0] = %q", dirs[0])
|
|
assert.True(t, strings.HasSuffix(dirs[1], filepath.Join(".local", "share", "devin")), "dirs[1] = %q", dirs[1])
|
|
assert.False(t, cfg.IsUserConfigured(parser.AgentDevin))
|
|
}
|
|
|
|
func TestLoadEnv_OverridesDataDir(t *testing.T) {
|
|
custom := setupTestEnv(t)
|
|
|
|
cfg, err := Default()
|
|
require.NoError(t, err)
|
|
cfg.loadEnv()
|
|
|
|
assert.Equal(t, custom, cfg.DataDir)
|
|
}
|
|
|
|
func TestLoadEnv_UsesPrefixedCursorAdminVarsWithLegacyFallback(t *testing.T) {
|
|
setupTestEnv(t)
|
|
t.Setenv("AGENTSVIEW_CURSOR_ADMIN_API_KEY", "prefixed-key")
|
|
t.Setenv("CURSOR_ADMIN_API_KEY", "legacy-key")
|
|
t.Setenv("CURSOR_ADMIN_EMAIL", "legacy@example.com")
|
|
t.Setenv("AGENTSVIEW_CURSOR_ADMIN_USER_ID", "prefixed-user")
|
|
|
|
cfg, err := Default()
|
|
require.NoError(t, err)
|
|
cfg.loadEnv()
|
|
|
|
assert.Equal(t, "prefixed-key", cfg.CursorAdminAPIKey)
|
|
assert.Equal(t, "legacy@example.com", cfg.CursorAdminEmail)
|
|
assert.Equal(t, "prefixed-user", cfg.CursorAdminUserID)
|
|
}
|
|
|
|
func TestLoadMinimal_PreservesCursorAdminEnvOverFile(t *testing.T) {
|
|
dir := setupTestEnv(t)
|
|
writeConfig(t, dir, map[string]any{
|
|
"cursor_admin_api_key": "file-key",
|
|
"cursor_admin_email": "file@example.com",
|
|
"cursor_admin_user_id": "file-user",
|
|
})
|
|
t.Setenv("AGENTSVIEW_CURSOR_ADMIN_API_KEY", "env-key")
|
|
t.Setenv("AGENTSVIEW_CURSOR_ADMIN_EMAIL", "env@example.com")
|
|
t.Setenv("AGENTSVIEW_CURSOR_ADMIN_USER_ID", "env-user")
|
|
|
|
cfg, err := LoadMinimal()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "env-key", cfg.CursorAdminAPIKey)
|
|
assert.Equal(t, "env@example.com", cfg.CursorAdminEmail)
|
|
assert.Equal(t, "env-user", cfg.CursorAdminUserID)
|
|
}
|
|
|
|
func TestLoadMinimal_PreservesAuthTokenEnvOverFile(t *testing.T) {
|
|
dir := setupTestEnv(t)
|
|
writeConfig(t, dir, map[string]any{
|
|
"auth_token": "file-token",
|
|
})
|
|
t.Setenv("AGENTSVIEW_AUTH_TOKEN", "env-token")
|
|
|
|
cfg, err := LoadMinimal()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "env-token", cfg.AuthToken)
|
|
}
|
|
|
|
func TestLoad_AppliesExplicitFlags(t *testing.T) {
|
|
cfg, err := loadConfigFromFlags(t, "-host", "0.0.0.0", "-port", "9090")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "0.0.0.0", cfg.Host)
|
|
assert.Equal(t, 9090, cfg.Port)
|
|
}
|
|
|
|
func TestLoad_DefaultsWithoutFlags(t *testing.T) {
|
|
cfg, err := loadConfigFromFlags(t)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "127.0.0.1", cfg.Host)
|
|
assert.Equal(t, 8080, cfg.Port)
|
|
assert.Empty(t, cfg.PublicOrigins)
|
|
}
|
|
|
|
func TestLoadPFlags_AppliesExplicitFlags(t *testing.T) {
|
|
cfg, err := loadConfigFromPFlags(t, "--host", "0.0.0.0", "--port", "9090")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "0.0.0.0", cfg.Host)
|
|
assert.Equal(t, 9090, cfg.Port)
|
|
}
|
|
|
|
func TestLoad_NilFlagSet(t *testing.T) {
|
|
setupTestEnv(t)
|
|
cfg, err := Load(nil)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "127.0.0.1", cfg.Host)
|
|
}
|
|
|
|
func TestLoad_PublicOriginFlagOverridesConfigFile(t *testing.T) {
|
|
tmp := setupTestEnv(t)
|
|
writeConfig(t, tmp, map[string]any{
|
|
"public_origins": []string{"https://old.example.test"},
|
|
})
|
|
|
|
cfg, err := loadConfigFromFlags(
|
|
t,
|
|
"-public-origin", "https://viewer.example.test/",
|
|
"-public-origin", "http://viewer.example.test:8004",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
got := strings.Join(cfg.PublicOrigins, ",")
|
|
assert.Equal(t, "https://viewer.example.test,http://viewer.example.test:8004", got)
|
|
}
|
|
|
|
func TestLoad_HostFromConfigFile(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, map[string]any{
|
|
"host": "0.0.0.0",
|
|
})
|
|
|
|
assert.Equal(t, "0.0.0.0", cfg.Host)
|
|
assert.False(t, cfg.HostExplicit,
|
|
"config-file host must not count as an explicit flag")
|
|
}
|
|
|
|
func TestLoad_HostFlagOverridesConfigFile(t *testing.T) {
|
|
tmp := setupTestEnv(t)
|
|
writeConfig(t, tmp, map[string]any{
|
|
"host": "0.0.0.0",
|
|
})
|
|
|
|
cfg, err := loadConfigFromFlags(t, "-host", "192.168.1.5")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "192.168.1.5", cfg.Host)
|
|
assert.True(t, cfg.HostExplicit)
|
|
}
|
|
|
|
func TestLoad_PortFromConfigFile(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, map[string]any{
|
|
"port": 7357,
|
|
})
|
|
|
|
assert.Equal(t, 7357, cfg.Port)
|
|
}
|
|
|
|
func TestLoad_PortFlagOverridesConfigFile(t *testing.T) {
|
|
tmp := setupTestEnv(t)
|
|
writeConfig(t, tmp, map[string]any{
|
|
"port": 7357,
|
|
})
|
|
|
|
cfg, err := loadConfigFromFlags(t, "-port", "9090")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 9090, cfg.Port)
|
|
}
|
|
|
|
func TestLoad_PublicOriginsFromConfigFile(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, map[string]any{
|
|
"public_origins": []string{
|
|
"https://Viewer.Example.Test:443/",
|
|
"http://viewer.example.test:8004",
|
|
},
|
|
})
|
|
|
|
got := strings.Join(cfg.PublicOrigins, ",")
|
|
assert.Equal(t, "https://viewer.example.test,http://viewer.example.test:8004", got)
|
|
}
|
|
|
|
func TestLoad_PublicOriginsRejectInvalid(t *testing.T) {
|
|
err := loadMinimalErrWithConfig(t, map[string]any{
|
|
"public_origins": []string{"ftp://viewer.example.test"},
|
|
})
|
|
|
|
requireErrorContains(t, err, "invalid public origins")
|
|
}
|
|
|
|
func TestLoad_PublicURLMergedIntoOrigins(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, map[string]any{
|
|
"public_url": "https://viewer.example.test/",
|
|
})
|
|
|
|
assert.Equal(t, "https://viewer.example.test", cfg.PublicURL)
|
|
assert.Equal(t, "https://viewer.example.test", strings.Join(cfg.PublicOrigins, ","))
|
|
}
|
|
|
|
func TestLoad_ProxyConfigFromFile(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, map[string]any{
|
|
"public_url": "https://viewer.example.test",
|
|
"proxy": map[string]any{
|
|
"mode": "caddy",
|
|
"bind_host": "10.0.60.2",
|
|
"public_port": 9443,
|
|
"tls_cert": "/tmp/viewer.crt",
|
|
"tls_key": "/tmp/viewer.key",
|
|
"allowed_subnets": []string{"10.1.2.3/16", "192.168.1.0/24"},
|
|
},
|
|
})
|
|
|
|
assert.Equal(t, "caddy", cfg.Proxy.Mode)
|
|
assert.Equal(t, "caddy", cfg.Proxy.Bin)
|
|
assert.Equal(t, "10.0.60.2", cfg.Proxy.BindHost)
|
|
assert.Equal(t, 9443, cfg.Proxy.PublicPort)
|
|
assert.Equal(t, "https://viewer.example.test:9443", cfg.PublicURL)
|
|
assert.Equal(t, "10.1.0.0/16,192.168.1.0/24", strings.Join(cfg.Proxy.AllowedSubnets, ","))
|
|
}
|
|
|
|
func TestLoad_ProxyFlags(t *testing.T) {
|
|
cfg, err := loadConfigFromFlags(
|
|
t,
|
|
"-public-url", "https://viewer.example.test",
|
|
"-proxy", "caddy",
|
|
"-proxy-bind-host", "0.0.0.0",
|
|
"-public-port", "9443",
|
|
"-tls-cert", "/tmp/viewer.crt",
|
|
"-tls-key", "/tmp/viewer.key",
|
|
"-allowed-subnet", "10.0/16",
|
|
"-allowed-subnet", "192.168.0.0/24",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "https://viewer.example.test:9443", cfg.PublicURL)
|
|
assert.Equal(t, "caddy", cfg.Proxy.Mode)
|
|
assert.Equal(t, "0.0.0.0", cfg.Proxy.BindHost)
|
|
assert.Equal(t, 9443, cfg.Proxy.PublicPort)
|
|
assert.Equal(t, "10.0.0.0/16,192.168.0.0/24", strings.Join(cfg.Proxy.AllowedSubnets, ","))
|
|
}
|
|
|
|
func TestLoad_ManagedCaddyDefaultsPublicPortAndBindHost(t *testing.T) {
|
|
cfg, err := loadConfigFromFlags(
|
|
t,
|
|
"-public-url", "https://viewer.example.test",
|
|
"-proxy", "caddy",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "https://viewer.example.test:8443", cfg.PublicURL)
|
|
assert.Equal(t, "127.0.0.1", cfg.Proxy.BindHost)
|
|
assert.Equal(t, 0, cfg.Proxy.PublicPort)
|
|
}
|
|
|
|
func TestLoad_ManagedCaddyRejectsConflictingPublicPort(t *testing.T) {
|
|
_, err := loadConfigFromFlags(
|
|
t,
|
|
"-public-url", "https://viewer.example.test:9443",
|
|
"-proxy", "caddy",
|
|
"-public-port", "8443",
|
|
)
|
|
requireErrorContains(t, err, "conflicts with configured public port")
|
|
}
|
|
|
|
func TestLoad_ManagedCaddyRejectsPublicURLPath(t *testing.T) {
|
|
_, err := loadConfigFromFlags(
|
|
t,
|
|
"-public-url", "https://viewer.example.test/path",
|
|
"-proxy", "caddy",
|
|
)
|
|
requireErrorContains(t, err, "must not include a path")
|
|
}
|
|
|
|
func TestLoad_ManagedCaddyNormalizesExplicitDefaultPorts(t *testing.T) {
|
|
cfg, err := loadConfigFromFlags(
|
|
t,
|
|
"-public-url", "https://viewer.example.test:443",
|
|
"-proxy", "caddy",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "https://viewer.example.test", cfg.PublicURL)
|
|
|
|
cfg, err = loadConfigFromFlags(
|
|
t,
|
|
"-public-url", "http://viewer.example.test:80",
|
|
"-proxy", "caddy",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "http://viewer.example.test", cfg.PublicURL)
|
|
}
|
|
|
|
func TestLoad_AllowedSubnetsRejectInvalid(t *testing.T) {
|
|
err := loadMinimalErrWithConfig(t, map[string]any{
|
|
"proxy": map[string]any{
|
|
"mode": "caddy",
|
|
"allowed_subnets": []string{"10.0.0.0/not-a-mask"},
|
|
},
|
|
})
|
|
|
|
requireErrorContains(t, err, "invalid allowed subnets")
|
|
}
|
|
|
|
func TestSaveGithubToken_RejectsCorruptConfig(t *testing.T) {
|
|
tmp := setupTestEnv(t)
|
|
cfg := Config{DataDir: tmp}
|
|
|
|
// Write invalid TOML to config file
|
|
path := filepath.Join(tmp, configFileName)
|
|
require.NoError(t, os.WriteFile(path, []byte("[invalid toml = ="), 0o600))
|
|
|
|
err := cfg.SaveGithubToken("tok")
|
|
require.Error(t, err, "expected error for corrupt config")
|
|
}
|
|
|
|
func TestSaveGithubToken_ReturnsErrorOnReadFailure(t *testing.T) {
|
|
skipIfNotUnix(t)
|
|
|
|
f := newConfigFixture(t)
|
|
cfg := Config{DataDir: f.Dir}
|
|
|
|
// Create a config file that is not readable
|
|
path := f.Path(configFileName)
|
|
require.NoError(t, os.WriteFile(path, []byte("k = \"v\"\n"), 0o000))
|
|
|
|
err := cfg.SaveGithubToken("tok")
|
|
requireErrorContains(t, err, "reading config file")
|
|
}
|
|
|
|
func TestSaveGithubToken_PreservesExistingKeys(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
cfg := Config{DataDir: f.Dir}
|
|
|
|
existing := map[string]any{"custom_key": "value"}
|
|
f.WriteTOML(t, existing)
|
|
|
|
require.NoError(t, cfg.SaveGithubToken("new-token"))
|
|
|
|
result := f.ReadTOMLMap(t)
|
|
assert.Equal(t, "value", result["custom_key"])
|
|
assert.Equal(t, "new-token", result["github_token"])
|
|
}
|
|
|
|
func TestEnsureAuthTokenConcurrentCallersSharePersistedToken(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
const workers = 8
|
|
tokens := make([]string, workers)
|
|
|
|
runConcurrent(t, workers, func(i int) error {
|
|
cfg := Config{DataDir: f.Dir}
|
|
if err := cfg.EnsureAuthToken(); err != nil {
|
|
return err
|
|
}
|
|
tokens[i] = cfg.AuthToken
|
|
return nil
|
|
})
|
|
|
|
requireAllSameNonEmpty(t, tokens)
|
|
result := f.ReadTOMLMap(t)
|
|
assert.Equal(t, tokens[0], result["auth_token"])
|
|
}
|
|
|
|
func TestEnsureCursorSecretConcurrentCallersSharePersistedSecret(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
const workers = 8
|
|
secrets := make([]string, workers)
|
|
|
|
runConcurrent(t, workers, func(i int) error {
|
|
cfg := Config{DataDir: f.Dir}
|
|
if err := cfg.ensureCursorSecret(); err != nil {
|
|
return err
|
|
}
|
|
secrets[i] = cfg.CursorSecret
|
|
return nil
|
|
})
|
|
|
|
requireAllSameNonEmpty(t, secrets)
|
|
result := f.ReadTOMLMap(t)
|
|
assert.Equal(t, secrets[0], result["cursor_secret"])
|
|
}
|
|
|
|
func TestMigrateJSONToTOMLConcurrentCallersMigrateOnce(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
jsonPath := f.Path("config.json")
|
|
f.WriteLegacyJSON(t, `{
|
|
"github_token": "legacy-token",
|
|
"require_auth": true
|
|
}`)
|
|
|
|
const workers = 4
|
|
runConcurrent(t, workers, func(int) error {
|
|
cfg := Config{DataDir: f.Dir}
|
|
return cfg.migrateJSONToTOML()
|
|
})
|
|
|
|
assert.NoFileExists(t, jsonPath)
|
|
assert.FileExists(t, jsonPath+".bak")
|
|
result := f.ReadTOMLMap(t)
|
|
assert.Equal(t, "legacy-token", result["github_token"])
|
|
assert.Equal(t, true, result["require_auth"])
|
|
}
|
|
|
|
func TestLoadFile_ReadsDirArrays(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, map[string]any{
|
|
"claude_project_dirs": []string{"/path/one", "/path/two"},
|
|
"codex_sessions_dirs": []string{"/codex/a"},
|
|
"aider_dirs": []string{"/code"},
|
|
})
|
|
|
|
claudeDirs := cfg.ResolveDirs(parser.AgentClaude)
|
|
require.Len(t, claudeDirs, 2)
|
|
assert.Equal(t, "/path/one", claudeDirs[0])
|
|
assert.Equal(t, "/path/two", claudeDirs[1])
|
|
codexDirs := cfg.ResolveDirs(parser.AgentCodex)
|
|
require.Len(t, codexDirs, 1)
|
|
assert.Equal(t, "/codex/a", codexDirs[0])
|
|
assert.Equal(t, []string{"/code"}, cfg.ResolveDirs(parser.AgentAider))
|
|
assert.True(t, cfg.IsUserConfigured(parser.AgentAider))
|
|
}
|
|
|
|
func TestResolveDirs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config map[string]any
|
|
envValue string
|
|
expectDefault bool
|
|
wantDirs []string
|
|
wantUserConfig bool
|
|
}{
|
|
{
|
|
"DefaultOnly",
|
|
map[string]any{},
|
|
"",
|
|
true,
|
|
nil,
|
|
false,
|
|
},
|
|
{
|
|
"ConfigOverrides",
|
|
map[string]any{
|
|
"claude_project_dirs": []string{"/a", "/b"},
|
|
},
|
|
"",
|
|
false,
|
|
[]string{"/a", "/b"},
|
|
true,
|
|
},
|
|
{
|
|
"EnvOverrides",
|
|
map[string]any{
|
|
"claude_project_dirs": []string{"/a"},
|
|
},
|
|
"/env/override",
|
|
false,
|
|
[]string{"/env/override"},
|
|
true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := setupTestEnv(t)
|
|
writeConfig(t, dir, tt.config)
|
|
t.Setenv("CLAUDE_CONFIG_DIR", "")
|
|
if tt.envValue != "" {
|
|
t.Setenv("CLAUDE_PROJECTS_DIR", tt.envValue)
|
|
}
|
|
|
|
cfg, err := LoadMinimal()
|
|
require.NoError(t, err)
|
|
|
|
dirs := cfg.ResolveDirs(parser.AgentClaude)
|
|
|
|
want := tt.wantDirs
|
|
if tt.expectDefault {
|
|
// Default is the home-dir based path
|
|
want = cfg.AgentDirs[parser.AgentClaude]
|
|
}
|
|
|
|
assert.Equal(t, want, dirs)
|
|
assert.Equal(t, tt.wantUserConfig, cfg.IsUserConfigured(parser.AgentClaude))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveDirs_ClaudeConfigDirRootEnvVar(t *testing.T) {
|
|
t.Run("root env re-roots implicit default", func(t *testing.T) {
|
|
dir := setupTestEnv(t)
|
|
root := t.TempDir()
|
|
t.Setenv("CLAUDE_CONFIG_DIR", root)
|
|
writeConfig(t, dir, map[string]any{})
|
|
|
|
cfg, err := LoadMinimal()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, []string{filepath.Join(root, "projects")},
|
|
cfg.ResolveDirs(parser.AgentClaude))
|
|
assert.False(t, cfg.IsUserConfigured(parser.AgentClaude))
|
|
})
|
|
|
|
t.Run("projects env beats root env", func(t *testing.T) {
|
|
dir := setupTestEnv(t)
|
|
root := t.TempDir()
|
|
t.Setenv("CLAUDE_CONFIG_DIR", root)
|
|
t.Setenv("CLAUDE_PROJECTS_DIR", "/env/override")
|
|
writeConfig(t, dir, map[string]any{})
|
|
|
|
cfg, err := LoadMinimal()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, []string{"/env/override"},
|
|
cfg.ResolveDirs(parser.AgentClaude))
|
|
assert.True(t, cfg.IsUserConfigured(parser.AgentClaude))
|
|
})
|
|
|
|
t.Run("config file beats root env", func(t *testing.T) {
|
|
dir := setupTestEnv(t)
|
|
root := t.TempDir()
|
|
t.Setenv("CLAUDE_CONFIG_DIR", root)
|
|
writeConfig(t, dir, map[string]any{
|
|
"claude_project_dirs": []string{"/from/config"},
|
|
})
|
|
|
|
cfg, err := LoadMinimal()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, []string{"/from/config"},
|
|
cfg.ResolveDirs(parser.AgentClaude))
|
|
assert.True(t, cfg.IsUserConfigured(parser.AgentClaude))
|
|
})
|
|
}
|
|
|
|
func TestResolveDirs_DevinPrecedenceAndMergeRules(t *testing.T) {
|
|
t.Run("config overrides defaults", func(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, map[string]any{
|
|
"devin_dirs": []string{"/from/config/devin"},
|
|
})
|
|
|
|
assert.Equal(t, []string{"/from/config/devin"},
|
|
cfg.ResolveDirs(parser.AgentDevin))
|
|
assert.True(t, cfg.IsUserConfigured(parser.AgentDevin))
|
|
})
|
|
|
|
t.Run("env overrides config", func(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteTOML(t, map[string]any{
|
|
"devin_dirs": []string{"/from/config/devin"},
|
|
})
|
|
t.Setenv("DEVIN_DIR", "/from/env/devin")
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
assert.Equal(t, []string{"/from/env/devin"},
|
|
cfg.ResolveDirs(parser.AgentDevin))
|
|
assert.True(t, cfg.IsUserConfigured(parser.AgentDevin))
|
|
})
|
|
|
|
t.Run("config file still applies when env unset", func(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteTOML(t, map[string]any{
|
|
"devin_dirs": []string{"/from/config/devin", "/second/config/devin"},
|
|
})
|
|
t.Setenv("DEVIN_DIR", "")
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
assert.Equal(t, []string{"/from/config/devin", "/second/config/devin"},
|
|
cfg.ResolveDirs(parser.AgentDevin))
|
|
assert.True(t, cfg.IsUserConfigured(parser.AgentDevin))
|
|
})
|
|
}
|
|
|
|
func TestResolveDataDir_DefaultAndEnvOverride(t *testing.T) {
|
|
// Without env override, should return default
|
|
dir, err := ResolveDataDir()
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, dir, "ResolveDataDir returned empty string")
|
|
|
|
// With env override, should return the override
|
|
custom := t.TempDir()
|
|
t.Setenv("AGENTSVIEW_DATA_DIR", custom)
|
|
dir, err = ResolveDataDir()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, custom, dir)
|
|
}
|
|
|
|
// TestDataDir_LegacyEnvFallback verifies that the legacy AGENT_VIEWER_DATA_DIR
|
|
// env var still takes effect when the canonical AGENTSVIEW_DATA_DIR is unset,
|
|
// and that the canonical name wins when both are set.
|
|
func TestDataDir_LegacyEnvFallback(t *testing.T) {
|
|
t.Run("legacy used when canonical unset", func(t *testing.T) {
|
|
legacy := t.TempDir()
|
|
t.Setenv("AGENT_VIEWER_DATA_DIR", legacy)
|
|
dir, err := ResolveDataDir()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, legacy, dir)
|
|
})
|
|
|
|
t.Run("canonical wins over legacy", func(t *testing.T) {
|
|
legacy := t.TempDir()
|
|
canonical := t.TempDir()
|
|
t.Setenv("AGENT_VIEWER_DATA_DIR", legacy)
|
|
t.Setenv("AGENTSVIEW_DATA_DIR", canonical)
|
|
dir, err := ResolveDataDir()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, canonical, dir, "canonical should win")
|
|
})
|
|
}
|
|
|
|
func TestEnvOverridesConfigFile(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteTOML(t, map[string]any{
|
|
"codex_sessions_dirs": []string{"/from/config"},
|
|
})
|
|
t.Setenv("CODEX_SESSIONS_DIR", "/from/env")
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
dirs := cfg.ResolveDirs(parser.AgentCodex)
|
|
assert.Equal(t, []string{"/from/env"}, dirs)
|
|
}
|
|
|
|
func TestLoadFile_MalformedDirValueLogsWarning(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
t.Setenv("CLAUDE_CONFIG_DIR", "")
|
|
|
|
// Write a config where claude_project_dirs is a string
|
|
// instead of a string array.
|
|
f.WriteTOML(t, map[string]any{
|
|
"claude_project_dirs": "/not/an/array",
|
|
})
|
|
|
|
// Capture log output during Load.
|
|
buf := captureLog(t)
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
// The malformed key should trigger a warning.
|
|
assertLogContains(t, buf, "claude_project_dirs", "expected string array")
|
|
|
|
// ResolveDirs should return the default (malformed value
|
|
// was not applied).
|
|
dirs := cfg.ResolveDirs(parser.AgentClaude)
|
|
home, _ := os.UserHomeDir()
|
|
defaultDir := filepath.Join(home, ".claude", "projects")
|
|
assert.Equal(t, []string{defaultDir}, dirs)
|
|
}
|
|
|
|
func TestDefault_ResultContentBlockedCategories(t *testing.T) {
|
|
cfg, err := Default()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, []string{"Read", "Glob"}, cfg.ResultContentBlockedCategories)
|
|
}
|
|
|
|
func TestLoadFile_ResultContentBlockedCategories(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config map[string]any
|
|
want []string
|
|
}{
|
|
{
|
|
"NoConfigFileUsesDefault",
|
|
map[string]any{},
|
|
[]string{"Read", "Glob"},
|
|
},
|
|
{
|
|
"ConfigFileOverridesWithCustomArray",
|
|
map[string]any{
|
|
"result_content_blocked_categories": []string{"Bash"},
|
|
},
|
|
[]string{"Bash"},
|
|
},
|
|
{
|
|
"ConfigFileWithMultipleCategories",
|
|
map[string]any{
|
|
"result_content_blocked_categories": []string{"Bash", "Write", "Edit"},
|
|
},
|
|
[]string{"Bash", "Write", "Edit"},
|
|
},
|
|
{
|
|
"ConfigFileWithEmptyArrayClearsBlocklist",
|
|
map[string]any{
|
|
"result_content_blocked_categories": []string{},
|
|
},
|
|
[]string{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, tt.config)
|
|
|
|
assert.Equal(t, tt.want, cfg.ResultContentBlockedCategories)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadFile_EventsCoalesceInterval(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config map[string]any
|
|
want time.Duration
|
|
}{
|
|
{
|
|
"NoConfigFileUsesDefault",
|
|
map[string]any{},
|
|
10 * time.Second,
|
|
},
|
|
{
|
|
"ConfigFileOverrides",
|
|
map[string]any{
|
|
"events_coalesce_interval": "5s",
|
|
},
|
|
5 * time.Second,
|
|
},
|
|
{
|
|
"ConfigFileExplicitZeroDisables",
|
|
map[string]any{
|
|
"events_coalesce_interval": "0s",
|
|
},
|
|
0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, tt.config)
|
|
assert.Equal(t, tt.want, cfg.EventsCoalesceInterval)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadFile_DaemonIdleTimeout(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data map[string]any
|
|
want time.Duration
|
|
}{
|
|
{name: "absent uses default", data: map[string]any{}, want: 20 * time.Minute},
|
|
{name: "configured", data: map[string]any{"daemon_idle_timeout": "6h"}, want: 6 * time.Hour},
|
|
{name: "explicit zero disables", data: map[string]any{"daemon_idle_timeout": "0s"}, want: 0},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, tt.data)
|
|
assert.Equal(t, tt.want, cfg.DaemonIdleTimeout)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadFile_PGConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config map[string]any
|
|
envURL string
|
|
want PGConfig
|
|
}{
|
|
{
|
|
"NoConfig",
|
|
map[string]any{},
|
|
"",
|
|
PGConfig{},
|
|
},
|
|
{
|
|
"FromConfigFile",
|
|
map[string]any{
|
|
"pg": map[string]any{
|
|
"url": "postgres://localhost/test",
|
|
"machine_name": "laptop",
|
|
},
|
|
},
|
|
"",
|
|
PGConfig{
|
|
URL: "postgres://localhost/test",
|
|
MachineName: "laptop",
|
|
},
|
|
},
|
|
{
|
|
"EnvOverridesConfig",
|
|
map[string]any{
|
|
"pg": map[string]any{
|
|
"url": "postgres://from-config",
|
|
},
|
|
},
|
|
"postgres://from-env",
|
|
PGConfig{
|
|
URL: "postgres://from-env",
|
|
},
|
|
},
|
|
{
|
|
"EnvURLMergesFileFields",
|
|
map[string]any{
|
|
"pg": map[string]any{
|
|
"url": "postgres://from-config",
|
|
"machine_name": "laptop",
|
|
},
|
|
},
|
|
"postgres://from-env",
|
|
PGConfig{
|
|
URL: "postgres://from-env",
|
|
MachineName: "laptop",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteTOML(t, tt.config)
|
|
if tt.envURL != "" {
|
|
t.Setenv("AGENTSVIEW_PG_URL", tt.envURL)
|
|
}
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
resolved, err := cfg.ResolvePG()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, tt.want.URL, resolved.URL)
|
|
if tt.want.MachineName == "" {
|
|
assert.NotEmpty(t, resolved.MachineName)
|
|
} else {
|
|
assert.Equal(t, tt.want.MachineName, resolved.MachineName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolvePGTarget_NamedTargets(t *testing.T) {
|
|
cfg := Config{
|
|
DefaultPG: "work",
|
|
PGTargets: map[string]PGConfig{
|
|
"work": {
|
|
URL: "postgres://work",
|
|
MachineName: "workbox",
|
|
},
|
|
"archive": {
|
|
URL: "postgres://archive",
|
|
MachineName: "archivebox",
|
|
},
|
|
},
|
|
pgEnvOverrides: pgEnvOverrides{
|
|
URL: "postgres://env-default",
|
|
MachineName: "envbox",
|
|
},
|
|
}
|
|
|
|
defaultTarget, err := cfg.ResolvePG()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "postgres://env-default", defaultTarget.URL)
|
|
assert.Equal(t, "envbox", defaultTarget.MachineName)
|
|
|
|
archiveTarget, err := cfg.ResolvePGTarget("archive")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "postgres://archive", archiveTarget.URL)
|
|
assert.Equal(t, "archivebox", archiveTarget.MachineName)
|
|
}
|
|
|
|
func TestResolvePGTargets_DefaultFirst(t *testing.T) {
|
|
cfg := Config{
|
|
DefaultPG: "work",
|
|
PGTargets: map[string]PGConfig{
|
|
"archive": {URL: "postgres://archive"},
|
|
"work": {URL: "postgres://work"},
|
|
},
|
|
}
|
|
|
|
targets, err := cfg.ResolvePGTargets()
|
|
require.NoError(t, err)
|
|
require.Len(t, targets, 2)
|
|
assert.Equal(t, "work", targets[0].Name)
|
|
assert.True(t, targets[0].IsDefault)
|
|
assert.Equal(t, "archive", targets[1].Name)
|
|
assert.False(t, targets[1].IsDefault)
|
|
}
|
|
|
|
func TestResolvePGTargets_OneNamedTargetWithoutDefault(t *testing.T) {
|
|
cfg := Config{
|
|
PGTargets: map[string]PGConfig{
|
|
"work": {URL: "postgres://work"},
|
|
},
|
|
}
|
|
|
|
targets, err := cfg.ResolvePGTargets()
|
|
require.NoError(t, err)
|
|
require.Len(t, targets, 1)
|
|
assert.Equal(t, "work", targets[0].Name)
|
|
assert.True(t, targets[0].IsDefault)
|
|
}
|
|
|
|
func TestResolvePGTargets_MultipleNamedTargetsRequireDefault(t *testing.T) {
|
|
cfg := Config{
|
|
PGTargets: map[string]PGConfig{
|
|
"work": {URL: "postgres://work"},
|
|
"archive": {URL: "postgres://archive"},
|
|
},
|
|
}
|
|
|
|
_, err := cfg.ResolvePGTargets()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "default_pg is required")
|
|
}
|
|
|
|
func TestLoadMinimal_DefersNamedPGValidationForNonPGCommands(t *testing.T) {
|
|
dir := setupTestEnv(t)
|
|
path := filepath.Join(dir, configFileName)
|
|
data := []byte(`
|
|
default_pg = "missing"
|
|
|
|
[pg.work]
|
|
url = "postgres://work"
|
|
`)
|
|
require.NoError(t, os.WriteFile(path, data, 0o600))
|
|
|
|
cfg, err := LoadMinimal()
|
|
require.NoError(t, err)
|
|
|
|
_, err = cfg.ResolvePG()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), `default_pg "missing" does not match any named [pg.NAME] target`)
|
|
}
|
|
|
|
func TestLoadFile_PGMixedLegacyAndNamedTargetsFails(t *testing.T) {
|
|
dir := setupTestEnv(t)
|
|
path := filepath.Join(dir, configFileName)
|
|
data := []byte(`
|
|
[pg]
|
|
url = "postgres://legacy"
|
|
|
|
[pg.archive]
|
|
url = "postgres://archive"
|
|
`)
|
|
require.NoError(t, os.WriteFile(path, data, 0o600))
|
|
|
|
_, err := LoadMinimal()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "cannot mix legacy [pg] fields with named [pg.NAME] targets")
|
|
}
|
|
|
|
func TestLoadFile_PGNamedTargetValidationErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
toml string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "reserved all target",
|
|
toml: `
|
|
[pg.all]
|
|
url = "postgres://all"
|
|
`,
|
|
wantErr: `named PG target "all" is reserved`,
|
|
},
|
|
{
|
|
name: "reserved local target",
|
|
toml: `
|
|
[pg.local]
|
|
url = "postgres://local"
|
|
`,
|
|
wantErr: `named PG target "local" is reserved`,
|
|
},
|
|
{
|
|
name: "duplicate normalized target names",
|
|
toml: `
|
|
[pg.Work]
|
|
url = "postgres://work"
|
|
|
|
[pg.work]
|
|
url = "postgres://work2"
|
|
`,
|
|
wantErr: `normalize to the same name "work"`,
|
|
},
|
|
{
|
|
name: "named target must be table",
|
|
toml: `
|
|
[pg]
|
|
archive = "postgres://archive"
|
|
`,
|
|
wantErr: `[pg].archive must be a named target table`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := setupTestEnv(t)
|
|
path := filepath.Join(dir, configFileName)
|
|
require.NoError(t, os.WriteFile(path, []byte(tt.toml), 0o600))
|
|
|
|
_, err := LoadMinimal()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.wantErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPGConfig_ProjectFilter(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteConfigText(t, `
|
|
[pg]
|
|
url = "postgres://localhost/test"
|
|
projects = ["alpha", "beta"]
|
|
`)
|
|
|
|
cfg := f.LoadFile(t)
|
|
|
|
require.Len(t, cfg.PG.Projects, 2)
|
|
assert.Equal(t, "alpha", cfg.PG.Projects[0])
|
|
assert.Equal(t, "beta", cfg.PG.Projects[1])
|
|
}
|
|
|
|
func TestPGConfig_ExcludeProjectFilter(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteConfigText(t, `
|
|
[pg]
|
|
url = "postgres://localhost/test"
|
|
exclude_projects = ["gamma"]
|
|
`)
|
|
|
|
cfg := f.LoadFile(t)
|
|
|
|
require.Len(t, cfg.PG.ExcludeProjects, 1)
|
|
assert.Equal(t, "gamma", cfg.PG.ExcludeProjects[0])
|
|
}
|
|
|
|
func TestEnsureAuthTokenAdoptsPersistedToken(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteConfigText(t, `
|
|
auth_token = "persisted-token"
|
|
require_auth = true
|
|
`)
|
|
|
|
cfg, err := Default()
|
|
require.NoError(t, err)
|
|
cfg.DataDir = f.Dir
|
|
cfg.RequireAuth = true
|
|
|
|
require.NoError(t, cfg.EnsureAuthToken())
|
|
assert.Equal(t, "persisted-token", cfg.AuthToken)
|
|
|
|
data, err := os.ReadFile(f.Path(configFileName))
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(data), `auth_token = "persisted-token"`)
|
|
}
|
|
|
|
func TestResolvePG_Defaults(t *testing.T) {
|
|
cfg := Config{
|
|
PG: PGConfig{
|
|
URL: "postgres://localhost/test",
|
|
},
|
|
}
|
|
resolved, err := cfg.ResolvePG()
|
|
require.NoError(t, err, "ResolvePG")
|
|
|
|
assert.Equal(t, "agentsview", resolved.Schema)
|
|
assert.NotEmpty(t, resolved.MachineName, "MachineName should default to hostname")
|
|
}
|
|
|
|
func TestLoadResolvesLocalMachineNameFromHostname(t *testing.T) {
|
|
setupTestEnv(t)
|
|
cfg, err := loadConfigFromPFlags(t)
|
|
require.NoError(t, err)
|
|
hostname, err := os.Hostname()
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, hostname, cfg.LocalMachineName)
|
|
}
|
|
|
|
func TestResolvePG_ExpandsEnvVars(t *testing.T) {
|
|
t.Setenv("PGPASS", "env-secret")
|
|
t.Setenv("PGURL", "postgres://localhost/test")
|
|
|
|
cfg := Config{
|
|
PG: PGConfig{
|
|
URL: "${PGURL}?password=${PGPASS}",
|
|
},
|
|
}
|
|
|
|
resolved, err := cfg.ResolvePG()
|
|
require.NoError(t, err, "ResolvePG")
|
|
|
|
assert.Equal(t, "postgres://localhost/test?password=env-secret", resolved.URL)
|
|
}
|
|
|
|
func TestResolvePG_ExpandsBareEnvOnlyForWholeValue(t *testing.T) {
|
|
t.Setenv("PGURL", "postgres://localhost/test")
|
|
|
|
cfg := Config{
|
|
PG: PGConfig{
|
|
URL: "$PGURL",
|
|
},
|
|
}
|
|
|
|
resolved, err := cfg.ResolvePG()
|
|
require.NoError(t, err, "ResolvePG")
|
|
|
|
assert.Equal(t, "postgres://localhost/test", resolved.URL)
|
|
}
|
|
|
|
func TestResolvePG_PreservesLiteralDollarSequencesInURL(t *testing.T) {
|
|
t.Setenv("PGPASS", "env-secret")
|
|
|
|
cfg := Config{
|
|
PG: PGConfig{
|
|
URL: "postgres://user:pa$word@localhost/db?application_name=$client&password=${PGPASS}",
|
|
},
|
|
}
|
|
|
|
resolved, err := cfg.ResolvePG()
|
|
require.NoError(t, err, "ResolvePG")
|
|
|
|
assert.Equal(t, "postgres://user:pa$word@localhost/db?application_name=$client&password=env-secret", resolved.URL)
|
|
}
|
|
|
|
func TestResolvePG_ErrorsOnMissingEnvVar(t *testing.T) {
|
|
cfg := Config{
|
|
PG: PGConfig{
|
|
URL: "${NONEXISTENT_PG_VAR}",
|
|
},
|
|
}
|
|
|
|
_, err := cfg.ResolvePG()
|
|
requireErrorContains(t, err, "NONEXISTENT_PG_VAR")
|
|
}
|
|
|
|
func TestResolvePG_ErrorsOnMissingBareEnvVar(t *testing.T) {
|
|
cfg := Config{
|
|
PG: PGConfig{
|
|
URL: "$NONEXISTENT_PG_BARE_VAR",
|
|
},
|
|
}
|
|
|
|
_, err := cfg.ResolvePG()
|
|
requireErrorContains(t, err, "NONEXISTENT_PG_BARE_VAR")
|
|
}
|
|
|
|
// TestIsEnvDependentURL locks the helper to the same expansion semantics
|
|
// as expandBracedEnv: any ${VAR}, or a whole-string bare $VAR, is
|
|
// env-dependent; an embedded bare $VAR or literal dollar sequence is not.
|
|
func TestIsEnvDependentURL(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want bool
|
|
}{
|
|
{"braced var", "${PGURL}", true},
|
|
{"braced var embedded", "postgres://h/db?password=${PGPASS}", true},
|
|
{"whole-string bare var", "$PGURL", true},
|
|
{"whole-string bare var with surrounding space", " $PGURL ", true},
|
|
{"embedded bare var not expanded", "postgres://$USER@host/db", false},
|
|
{"literal dollar sequence", "postgres://user:pa$word@host/db", false},
|
|
{"plain literal", "postgres://user:pass@localhost/db?sslmode=disable", false},
|
|
{"empty", "", false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
assert.Equal(t, c.want, IsEnvDependentURL(c.in))
|
|
})
|
|
}
|
|
}
|
|
|
|
// ResolvePG must not reject configs with both filter lists —
|
|
// that's a push-specific concern validated in runPGPush after
|
|
// CLI flags are merged. status and serve use ResolvePG too and
|
|
// shouldn't fail on push-only filter conflicts.
|
|
func TestResolvePG_AllowsBothFilterLists(t *testing.T) {
|
|
cfg := Config{
|
|
PG: PGConfig{
|
|
URL: "postgres://localhost/test",
|
|
Projects: []string{"alpha"},
|
|
ExcludeProjects: []string{"beta"},
|
|
},
|
|
}
|
|
_, err := cfg.ResolvePG()
|
|
require.NoError(t, err, "ResolvePG should not reject filter conflicts")
|
|
}
|
|
|
|
func TestDuckDBConfig_LoadsFileAndEnv(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteTOML(t, map[string]any{
|
|
"duckdb": map[string]any{
|
|
"path": "/from/config/sessions.duckdb",
|
|
"url": "quack:config-host",
|
|
"token": "config-token",
|
|
"machine_name": "config-machine",
|
|
"allow_insecure": true,
|
|
"projects": []string{"alpha", "beta"},
|
|
"exclude_projects": []string{"gamma"},
|
|
},
|
|
})
|
|
t.Setenv("AGENTSVIEW_DUCKDB_PATH", "/from/env/sessions.duckdb")
|
|
t.Setenv("AGENTSVIEW_DUCKDB_URL", "quack:env-host")
|
|
t.Setenv("AGENTSVIEW_DUCKDB_TOKEN", "env-token")
|
|
t.Setenv("AGENTSVIEW_DUCKDB_MACHINE", "env-machine")
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
assert.Equal(t, "/from/env/sessions.duckdb", cfg.DuckDB.Path)
|
|
assert.Equal(t, "quack:env-host", cfg.DuckDB.URL)
|
|
assert.Equal(t, "env-token", cfg.DuckDB.Token)
|
|
assert.Equal(t, "env-machine", cfg.DuckDB.MachineName)
|
|
assert.True(t, cfg.DuckDB.AllowInsecure)
|
|
assert.Equal(t, []string{"alpha", "beta"}, cfg.DuckDB.Projects)
|
|
assert.Equal(t, []string{"gamma"}, cfg.DuckDB.ExcludeProjects)
|
|
}
|
|
|
|
func TestResolveDuckDB_Defaults(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfg := Config{DataDir: dir}
|
|
|
|
resolved, err := cfg.ResolveDuckDB()
|
|
require.NoError(t, err, "ResolveDuckDB")
|
|
|
|
assert.Equal(t, filepath.Join(dir, "sessions.duckdb"), resolved.Path)
|
|
assert.NotEmpty(t, resolved.MachineName, "MachineName should default to hostname")
|
|
}
|
|
|
|
func TestResolveDuckDB_ExpandsEnvVars(t *testing.T) {
|
|
t.Setenv("DUCKDB_URL", "quack:localhost")
|
|
t.Setenv("DUCKDB_TOKEN", "secret-token")
|
|
t.Setenv("DUCKDB_PATH", filepath.Join(t.TempDir(), "remote.duckdb"))
|
|
|
|
cfg := Config{
|
|
DuckDB: DuckDBConfig{
|
|
Path: "$DUCKDB_PATH",
|
|
URL: "${DUCKDB_URL}",
|
|
Token: "${DUCKDB_TOKEN}",
|
|
},
|
|
}
|
|
|
|
resolved, err := cfg.ResolveDuckDB()
|
|
require.NoError(t, err, "ResolveDuckDB")
|
|
|
|
assert.Equal(t, os.Getenv("DUCKDB_PATH"), resolved.Path)
|
|
assert.Equal(t, "quack:localhost", resolved.URL)
|
|
assert.Equal(t, "secret-token", resolved.Token)
|
|
}
|
|
|
|
func TestResolveDuckDB_ErrorsOnMissingEnvVar(t *testing.T) {
|
|
cfg := Config{
|
|
DuckDB: DuckDBConfig{
|
|
URL: "${MISSING_DUCKDB_URL}",
|
|
},
|
|
}
|
|
|
|
_, err := cfg.ResolveDuckDB()
|
|
requireErrorContains(t, err, "MISSING_DUCKDB_URL")
|
|
}
|
|
|
|
func TestAutomatedConfigRoundTrip(t *testing.T) {
|
|
cfg := loadPFlagsWithConfig(t, map[string]any{
|
|
"automated": map[string]any{
|
|
"prefixes": []string{
|
|
"You are analyzing an essay",
|
|
"You are grading quotes",
|
|
" ", // whitespace preserved here; normalization is db-side
|
|
"You are analyzing an essay", // duplicate preserved here too
|
|
},
|
|
"substrings": []string{
|
|
"invoked by roborev to perform this review",
|
|
" embedded marker ",
|
|
"invoked by roborev to perform this review",
|
|
},
|
|
"exact_matches": []string{
|
|
"Warmup",
|
|
" Reply with exactly OK. ",
|
|
"Warmup",
|
|
},
|
|
},
|
|
})
|
|
wantPrefixes := []string{
|
|
"You are analyzing an essay",
|
|
"You are grading quotes",
|
|
" ",
|
|
"You are analyzing an essay",
|
|
}
|
|
wantSubstrings := []string{
|
|
"invoked by roborev to perform this review",
|
|
" embedded marker ",
|
|
"invoked by roborev to perform this review",
|
|
}
|
|
wantExactMatches := []string{
|
|
"Warmup",
|
|
" Reply with exactly OK. ",
|
|
"Warmup",
|
|
}
|
|
assert.Equal(t, wantPrefixes, cfg.Automated.Prefixes)
|
|
assert.Equal(t, wantSubstrings, cfg.Automated.Substrings)
|
|
assert.Equal(t, wantExactMatches, cfg.Automated.ExactMatches)
|
|
}
|
|
|
|
func TestAutomatedConfigAbsentIsNil(t *testing.T) {
|
|
cfg := loadPFlagsWithConfig(t, map[string]any{
|
|
"public_url": "http://example.com",
|
|
})
|
|
assert.Nil(t, cfg.Automated.Prefixes)
|
|
assert.Nil(t, cfg.Automated.Substrings)
|
|
assert.Nil(t, cfg.Automated.ExactMatches)
|
|
}
|
|
|
|
func TestLoadFile_CustomModelPricing(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data map[string]any
|
|
want map[string]CustomModelRate
|
|
}{
|
|
{
|
|
name: "basic rates",
|
|
data: map[string]any{
|
|
"custom_model_pricing": map[string]CustomModelRate{
|
|
"acme-ultra-2.1": {Input: 2.0, Output: 8.0},
|
|
},
|
|
},
|
|
want: map[string]CustomModelRate{
|
|
"acme-ultra-2.1": {Input: 2.0, Output: 8.0},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple models with cache rates",
|
|
data: map[string]any{
|
|
"custom_model_pricing": map[string]CustomModelRate{
|
|
"acme-ultra-2.1": {Input: 2.0, Output: 8.0, CacheCreation: 2.5, CacheRead: 0.2},
|
|
"acme-fast-2.1": {Input: 0.8, Output: 4.0},
|
|
},
|
|
},
|
|
want: map[string]CustomModelRate{
|
|
"acme-ultra-2.1": {Input: 2.0, Output: 8.0, CacheCreation: 2.5, CacheRead: 0.2},
|
|
"acme-fast-2.1": {Input: 0.8, Output: 4.0},
|
|
},
|
|
},
|
|
{
|
|
name: "empty map omitted",
|
|
data: map[string]any{},
|
|
want: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t, tt.data)
|
|
|
|
if len(tt.want) == 0 {
|
|
assert.Empty(t, cfg.CustomModelPricing)
|
|
return
|
|
}
|
|
|
|
require.Len(t, cfg.CustomModelPricing, len(tt.want))
|
|
for model, wantRate := range tt.want {
|
|
got, ok := cfg.CustomModelPricing[model]
|
|
if !ok {
|
|
t.Errorf("missing model %q", model)
|
|
continue
|
|
}
|
|
assert.Equal(t, wantRate, got, "model %q", model)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadFile_RemoteHosts(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteConfigText(t, `[[remote_hosts]]
|
|
host = "devbox1"
|
|
user = "jesse"
|
|
port = 22
|
|
interval = "5m"
|
|
|
|
[[remote_hosts]]
|
|
host = " laptop2 "
|
|
`)
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
require.Len(t, cfg.RemoteHosts, 2)
|
|
assert.Equal(t, RemoteHost{Host: "devbox1", User: "jesse", Port: 22, Interval: 5 * time.Minute}, cfg.RemoteHosts[0])
|
|
assert.Equal(t, 5*time.Minute, cfg.RemoteHosts[0].Interval)
|
|
assert.Equal(t, time.Duration(0), cfg.RemoteHosts[1].Interval)
|
|
// host is trimmed at load so validation and SSH see the same value
|
|
assert.Equal(t, RemoteHost{Host: "laptop2"}, cfg.RemoteHosts[1])
|
|
}
|
|
|
|
func TestLoadFile_RemoteHostsHTTP(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteConfigText(t, `[[remote_hosts]]
|
|
host = "devbox1"
|
|
transport = "http"
|
|
url = "http://devbox1.tailnet.ts.net:8080"
|
|
token = "remote-token"
|
|
interval = "5m"
|
|
`)
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
require.Len(t, cfg.RemoteHosts, 1)
|
|
assert.Equal(t, RemoteHost{
|
|
Host: "devbox1",
|
|
Transport: RemoteTransportHTTP,
|
|
URL: "http://devbox1.tailnet.ts.net:8080",
|
|
Token: "remote-token",
|
|
Interval: 5 * time.Minute,
|
|
}, cfg.RemoteHosts[0])
|
|
}
|
|
|
|
func TestLoadFile_InvalidRemoteHostsDoNotBlockConfigLoad(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteConfigText(t, `[[remote_hosts]]
|
|
host = "devbox1"
|
|
transport = "http"
|
|
`)
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
require.Len(t, cfg.RemoteHosts, 1)
|
|
assert.Equal(t, RemoteTransportHTTP, cfg.RemoteHosts[0].Transport)
|
|
assert.Equal(t, "devbox1", cfg.RemoteHosts[0].Host)
|
|
}
|
|
|
|
func TestLoadFile_RemoteHostsAbsentIsNil(t *testing.T) {
|
|
cfg := loadMinimalWithConfig(t,
|
|
map[string]any{"public_url": "http://example.com"})
|
|
|
|
assert.Nil(t, cfg.RemoteHosts)
|
|
}
|
|
|
|
func TestValidateRemoteHosts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hosts []RemoteHost
|
|
wantErr []string // substrings expected in error; empty => no error
|
|
}{
|
|
{"valid", []RemoteHost{{Host: "a"}, {Host: "b", Port: 22}, {Host: "c", Port: 0}}, nil},
|
|
{"empty host", []RemoteHost{{Host: ""}}, []string{"host is required"}},
|
|
{"negative port", []RemoteHost{{Host: "a", Port: -1}}, []string{"invalid port"}},
|
|
{"port too large", []RemoteHost{{Host: "a", Port: 70000}}, []string{"invalid port"}},
|
|
{"aggregates both", []RemoteHost{{Host: ""}, {Host: "b", Port: 99999}}, []string{"host is required", "invalid port"}},
|
|
{"duplicate host", []RemoteHost{{Host: "a"}, {Host: "a"}}, []string{"duplicate host"}},
|
|
{"duplicate host different user or port", []RemoteHost{{Host: "box", User: "alice"}, {Host: "box", User: "bob", Port: 2222}}, []string{"duplicate host"}},
|
|
{"option shaped host", []RemoteHost{{Host: "-oProxyCommand=sh"}}, []string{"host must not begin with '-'"}},
|
|
{"option shaped user", []RemoteHost{{Host: "box", User: "-lroot"}}, []string{"user must not begin with '-'"}},
|
|
{"none configured", nil, nil},
|
|
{"negative interval", []RemoteHost{{Host: "a", Interval: -1}}, []string{"invalid interval"}},
|
|
{"zero interval ok", []RemoteHost{{Host: "a", Interval: 0}}, nil},
|
|
{"http valid", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "https://a.example.test", Token: "token"}}, nil},
|
|
{"http requires url", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP}}, []string{"url is required"}},
|
|
{"http requires token", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "https://a.example.test"}}, []string{"token is required"}},
|
|
{"http rejects user", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "https://a.example.test", User: "alice"}}, []string{"user is only valid for ssh"}},
|
|
{"http rejects port", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "https://a.example.test", Port: 443}}, []string{"port is only valid for ssh"}},
|
|
{"http rejects bad scheme", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "ftp://a.example.test"}}, []string{"url must use http or https"}},
|
|
{"http rejects empty hostname with port", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "http://:8080"}}, []string{"url must include a host"}},
|
|
{"http rejects query", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "https://a.example.test?token=x"}}, []string{"url must not include query"}},
|
|
{"http rejects empty query delimiter", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "https://a.example.test?", Token: "token"}}, []string{"url must not include query"}},
|
|
{"http rejects fragment", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "https://a.example.test/#remote"}}, []string{"url must not include fragment"}},
|
|
{"http rejects empty fragment delimiter", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "https://a.example.test#", Token: "token"}}, []string{"url must not include fragment"}},
|
|
{"http rejects userinfo", []RemoteHost{{Host: "a", Transport: RemoteTransportHTTP, URL: "https://alice@a.example.test"}}, []string{"url must not include userinfo"}},
|
|
{"ssh rejects url", []RemoteHost{{Host: "a", Transport: RemoteTransportSSH, URL: "https://a.example.test"}}, []string{"url is only valid for http"}},
|
|
{"ssh rejects token", []RemoteHost{{Host: "a", Transport: RemoteTransportSSH, Token: "token"}}, []string{"token is only valid for http"}},
|
|
{"unknown transport", []RemoteHost{{Host: "a", Transport: RemoteTransport("sftp")}}, []string{"invalid transport"}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := Config{RemoteHosts: tt.hosts}.ValidateRemoteHosts()
|
|
if len(tt.wantErr) == 0 {
|
|
require.NoError(t, err)
|
|
return
|
|
}
|
|
requireErrorContains(t, err, tt.wantErr...)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadFile_SyncIncludeCwdPrefixes(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteConfigText(t, `sync_include_cwd_prefixes = ["/home/me/work", "/home/me/oss"]
|
|
`)
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
assert.Equal(t,
|
|
[]string{"/home/me/work", "/home/me/oss"},
|
|
cfg.SyncIncludeCwdPrefixes,
|
|
)
|
|
}
|
|
|
|
func TestLoadFile_SyncIncludeCwdPrefixesDefaultsEmpty(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteConfigText(t, `host = "127.0.0.1"
|
|
`)
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
assert.Empty(t, cfg.SyncIncludeCwdPrefixes)
|
|
}
|
|
|
|
func TestIsDefaultAgentsviewDBPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A plain file inside a real ~/.agentsview directory.
|
|
defaultDir := filepath.Join(t.TempDir(), ".agentsview")
|
|
require.NoError(t, os.MkdirAll(defaultDir, 0o700))
|
|
defaultDB := filepath.Join(defaultDir, "sessions.db")
|
|
require.NoError(t, os.WriteFile(defaultDB, []byte("db"), 0o600))
|
|
|
|
// A plain file outside ~/.agentsview.
|
|
labDir := filepath.Join(t.TempDir(), "recall-lab-data")
|
|
require.NoError(t, os.MkdirAll(labDir, 0o700))
|
|
labDB := filepath.Join(labDir, "sessions.db")
|
|
require.NoError(t, os.WriteFile(labDB, []byte("db"), 0o600))
|
|
|
|
// A symlink whose target already exists inside ~/.agentsview.
|
|
liveLink := filepath.Join(labDir, "live-link.db")
|
|
require.NoError(t, os.Symlink(defaultDB, liveLink))
|
|
|
|
// An absolute symlink whose target does not exist yet. Opening SQLite
|
|
// through it would create the production archive, so it must be guarded
|
|
// even though EvalSymlinks cannot resolve the dangling target.
|
|
danglingLink := filepath.Join(labDir, "dangling.db")
|
|
require.NoError(t, os.Symlink(
|
|
filepath.Join(defaultDir, "not-created-yet.db"), danglingLink,
|
|
))
|
|
|
|
// A relative dangling symlink resolves against the link's own directory.
|
|
siblingRoot := t.TempDir()
|
|
require.NoError(t, os.MkdirAll(
|
|
filepath.Join(siblingRoot, ".agentsview"), 0o700,
|
|
))
|
|
relLinkDir := filepath.Join(siblingRoot, "lab")
|
|
require.NoError(t, os.MkdirAll(relLinkDir, 0o700))
|
|
relLink := filepath.Join(relLinkDir, "sessions.db")
|
|
require.NoError(t, os.Symlink(
|
|
filepath.Join("..", ".agentsview", "missing.db"), relLink,
|
|
))
|
|
|
|
// A symlink pointing at a harmless location is not the default archive.
|
|
safeLink := filepath.Join(labDir, "safe-link.db")
|
|
require.NoError(t, os.Symlink(labDB, safeLink))
|
|
|
|
tests := []struct {
|
|
name string
|
|
dbPath string
|
|
want bool
|
|
}{
|
|
{"empty", "", false},
|
|
{"whitespace", " ", false},
|
|
{"plain file in default dir", defaultDB, true},
|
|
{"plain file outside default dir", labDB, false},
|
|
{"symlink to existing default db", liveLink, true},
|
|
{"absolute dangling symlink into default dir", danglingLink, true},
|
|
{"relative dangling symlink into default dir", relLink, true},
|
|
{"symlink to non-default db", safeLink, false},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.want, IsDefaultAgentsviewDBPath(tc.dbPath))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPGConfigPushVectorsEnabled(t *testing.T) {
|
|
boolPtr := func(b bool) *bool { return &b }
|
|
tests := []struct {
|
|
name string
|
|
cfg PGConfig
|
|
want bool
|
|
}{
|
|
{"unset defaults to true", PGConfig{}, true},
|
|
{"explicit false", PGConfig{PushVectors: boolPtr(false)}, false},
|
|
{"explicit true", PGConfig{PushVectors: boolPtr(true)}, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, tt.cfg.PushVectorsEnabled())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPGConfig_LoadsFromTOML(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteTOML(t, map[string]any{
|
|
"pg": map[string]any{
|
|
"url": "postgres://localhost/test",
|
|
"push_vectors": false,
|
|
},
|
|
})
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
assert.False(t, cfg.PG.PushVectorsEnabled())
|
|
}
|
|
|
|
func TestPGConfig_PushVectorsDefaultsTrue(t *testing.T) {
|
|
f := newConfigFixture(t)
|
|
f.WriteTOML(t, map[string]any{
|
|
"pg": map[string]any{
|
|
"url": "postgres://localhost/test",
|
|
},
|
|
})
|
|
|
|
cfg := f.LoadMinimal(t)
|
|
|
|
assert.True(t, cfg.PG.PushVectorsEnabled())
|
|
}
|