Files
relata/internal/config/config_test.go
T
2026-08-11 10:40:10 +01:00

403 lines
12 KiB
Go

package config
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// clearEnv removes every RELATA_ variable so a test starts from defaults.
func clearEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
if k, _, ok := strings.Cut(kv, "="); ok && strings.HasPrefix(k, "RELATA_") {
t.Setenv(k, "")
os.Unsetenv(k)
}
}
}
func TestDefaultsAreSafe(t *testing.T) {
clearEnv(t)
cfg, err := Load("")
if err != nil {
t.Fatalf("the defaults do not pass validation: %v", err)
}
// The single most important default: not reachable from the network.
if cfg.HTTP.Addr != "127.0.0.1:8080" {
t.Errorf("default listen address = %q, want loopback", cfg.HTTP.Addr)
}
if cfg.HTTP.AllowExternalBind {
t.Error("external binding is permitted by default")
}
if cfg.HTTP.TrustProxyHeaders {
t.Error("proxy headers are trusted by default, which would let anyone defeat rate limiting")
}
if cfg.Workers.MaxAttempts <= 0 || cfg.Ollama.MaxRetries < 0 {
t.Error("retries are unbounded by default")
}
}
// Binding to a non-loopback interface must be a deliberate act.
func TestExternalBindRequiresExplicitOptIn(t *testing.T) {
clearEnv(t)
t.Setenv("RELATA_HTTP_ADDR", "0.0.0.0:8080")
_, err := Load("")
if err == nil {
t.Fatal("binding to 0.0.0.0 was accepted without an explicit opt-in")
}
if !strings.Contains(err.Error(), "RELATA_ALLOW_EXTERNAL_BIND") {
t.Fatalf("the error does not name the setting that permits it: %v", err)
}
t.Setenv("RELATA_ALLOW_EXTERNAL_BIND", "true")
if _, err := Load(""); err != nil {
t.Fatalf("an explicit opt-in was still refused: %v", err)
}
}
func TestLoopbackVariantsAreAccepted(t *testing.T) {
for _, addr := range []string{"127.0.0.1:8080", "localhost:8080", "[::1]:8080", "127.0.0.5:9000"} {
clearEnv(t)
t.Setenv("RELATA_HTTP_ADDR", addr)
if _, err := Load(""); err != nil {
t.Errorf("loopback address %q was refused: %v", addr, err)
}
}
}
// An HTTPS base URL must imply Secure cookies; forgetting that would send the
// session cookie over plain HTTP.
func TestHTTPSBaseURLEnablesSecureCookies(t *testing.T) {
clearEnv(t)
t.Setenv("RELATA_BASE_URL", "https://relata.example.org")
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if !cfg.HTTP.SecureCookies {
t.Fatal("an https base URL did not enable Secure cookies")
}
}
func TestValidationReportsEveryProblemAtOnce(t *testing.T) {
clearEnv(t)
t.Setenv("RELATA_MONGO_URI", "not-a-mongo-uri")
t.Setenv("RELATA_QDRANT_URL", "ftp://wrong")
t.Setenv("RELATA_OLLAMA_BASE_URL", "nonsense")
t.Setenv("RELATA_LOG_LEVEL", "verbose")
_, err := Load("")
if err == nil {
t.Fatal("an invalid configuration was accepted")
}
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("wrong error type: %T", err)
}
// Reporting one problem at a time would mean several restarts to fix.
if len(ve.Problems) < 4 {
t.Fatalf("only %d problems reported, expected all four:\n%v", len(ve.Problems), ve.Problems)
}
for _, want := range []string{"RELATA_MONGO_URI", "RELATA_QDRANT_URL", "RELATA_OLLAMA_BASE_URL", "RELATA_LOG_LEVEL"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the error does not mention %s", want)
}
}
}
func TestNonsensicalLimitsAreRejected(t *testing.T) {
cases := map[string]map[string]string{
"zero workers": {"RELATA_WORKER_COUNT": "0"},
"zero AI concurrency": {"RELATA_AI_CONCURRENCY": "0"},
"negative upload limit": {"RELATA_MAX_UPLOAD_BYTES": "-1"},
"absurd JSON depth": {"RELATA_MAX_JSON_DEPTH": "5000"},
"chunk min above max": {"RELATA_CHUNK_MAX_CHARS": "100", "RELATA_CHUNK_MIN_CHARS": "500"},
"overlap exceeds chunk": {"RELATA_CHUNK_OVERLAP_CHARS": "99999"},
"store below min score": {"RELATA_REL_MIN_SCORE": "0.8", "RELATA_REL_STORE_SCORE": "0.2"},
"score outside 0..1": {"RELATA_REL_MIN_SCORE": "5"},
"absolute below idle": {"RELATA_SESSION_IDLE_TIMEOUT": "48h", "RELATA_SESSION_ABSOLUTE_TIMEOUT": "1h"},
"too many retries": {"RELATA_AI_MAX_RETRIES": "50"},
"near-duplicate above 1": {"RELATA_NEAR_DUPLICATE_SIMILARITY": "1.5"},
}
for name, env := range cases {
t.Run(name, func(t *testing.T) {
clearEnv(t)
for k, v := range env {
t.Setenv(k, v)
}
if _, err := Load(""); err == nil {
t.Fatalf("%s was accepted", name)
}
})
}
}
// A heartbeat at or beyond the lease duration means a running job loses its
// lease and is reclaimed while still working.
func TestHeartbeatMustBeShorterThanTheLease(t *testing.T) {
clearEnv(t)
t.Setenv("RELATA_JOB_LEASE_DURATION", "30s")
t.Setenv("RELATA_JOB_HEARTBEAT", "60s")
_, err := Load("")
if err == nil {
t.Fatal("a heartbeat longer than the lease was accepted")
}
if !strings.Contains(err.Error(), "leases will expire mid-job") {
t.Fatalf("the error does not explain the consequence: %v", err)
}
}
func TestAnalysisModelRequiredOnlyWhenAnalysisEnabled(t *testing.T) {
clearEnv(t)
// A whitespace-only environment value falls back to the default model, so
// the rule is exercised against a Config built directly.
base, err := Load("")
if err != nil {
t.Fatal(err)
}
withModel := *base
withModel.Ollama.AnalysisModel = ""
withModel.Ollama.AnalysisEnabled = true
if err := withModel.Validate(); err == nil {
t.Fatal("analysis was enabled with no model configured")
}
disabled := *base
disabled.Ollama.AnalysisModel = ""
disabled.Ollama.AnalysisEnabled = false
if err := disabled.Validate(); err != nil {
t.Fatalf("a missing analysis model was refused even with analysis disabled: %v", err)
}
}
// Whitespace-only values fall back to the default rather than becoming empty,
// so a stray space in .env cannot silently unset a required setting.
func TestWhitespaceValuesFallBackToDefaults(t *testing.T) {
clearEnv(t)
t.Setenv("RELATA_EMBEDDING_MODEL", " ")
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if cfg.Ollama.EmbeddingModel != "nomic-embed-text" {
t.Fatalf("embedding model = %q, want the default", cfg.Ollama.EmbeddingModel)
}
}
// --- Configuration file ----------------------------------------------------
func TestConfigFileIsRead(t *testing.T) {
clearEnv(t)
path := filepath.Join(t.TempDir(), "test.env")
content := `# A comment
RELATA_LOG_LEVEL=debug
RELATA_WORKER_COUNT=7
RELATA_QDRANT_COLLECTION="quoted_value"
RELATA_MONGO_DATABASE='single_quoted'
MALFORMED_LINE_WITHOUT_EQUALS
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.LogLevel != "debug" {
t.Errorf("log level = %q, want debug", cfg.LogLevel)
}
if cfg.Workers.Count != 7 {
t.Errorf("worker count = %d, want 7", cfg.Workers.Count)
}
if cfg.Qdrant.Collection != "quoted_value" {
t.Errorf("quotes were not stripped: %q", cfg.Qdrant.Collection)
}
if cfg.Mongo.Database != "single_quoted" {
t.Errorf("single quotes were not stripped: %q", cfg.Mongo.Database)
}
}
// A real environment variable must beat the file, so a deployment can override
// without editing a shipped file.
func TestEnvironmentOverridesTheConfigFile(t *testing.T) {
clearEnv(t)
path := filepath.Join(t.TempDir(), "test.env")
if err := os.WriteFile(path, []byte("RELATA_LOG_LEVEL=debug\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("RELATA_LOG_LEVEL", "warn")
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.LogLevel != "warn" {
t.Fatalf("log level = %q; the file overrode the environment", cfg.LogLevel)
}
}
func TestMissingConfigFileIsNotAnError(t *testing.T) {
clearEnv(t)
if _, err := Load(filepath.Join(t.TempDir(), "does-not-exist.env")); err != nil {
t.Fatalf("a missing config file was treated as fatal: %v", err)
}
}
// --- Redaction -------------------------------------------------------------
func TestRedactedOutputContainsNoSecrets(t *testing.T) {
clearEnv(t)
t.Setenv("RELATA_QDRANT_API_KEY", "super-secret-key-value")
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
red := cfg.Redacted()
for k, v := range red {
if s, ok := v.(string); ok && strings.Contains(s, "super-secret") {
t.Fatalf("field %q leaked the API key: %q", k, s)
}
}
if red["qdrant_api_key"] != "[set]" {
t.Fatalf("qdrant_api_key = %v, want the marker [set]", red["qdrant_api_key"])
}
}
func TestRedactedShowsAbsentSecretAsEmpty(t *testing.T) {
clearEnv(t)
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if cfg.Redacted()["qdrant_api_key"] != "" {
t.Fatal("an unset secret is reported as set")
}
}
// --- Parsing ---------------------------------------------------------------
func TestDurationsAndNumbersParse(t *testing.T) {
clearEnv(t)
t.Setenv("RELATA_SESSION_IDLE_TIMEOUT", "90m")
t.Setenv("RELATA_MAX_UPLOAD_BYTES", "1048576")
t.Setenv("RELATA_REL_MIN_SCORE", "0.42")
t.Setenv("RELATA_REL_STORE_SCORE", "0.55") // must not be below the minimum
t.Setenv("RELATA_METRICS_ENABLED", "false")
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if cfg.Session.IdleTimeout != 90*time.Minute {
t.Errorf("idle timeout = %v, want 90m", cfg.Session.IdleTimeout)
}
if cfg.Uploads.MaxUploadBytes != 1048576 {
t.Errorf("upload limit = %d", cfg.Uploads.MaxUploadBytes)
}
if cfg.Rel.MinScore != 0.42 {
t.Errorf("min score = %v", cfg.Rel.MinScore)
}
if cfg.HTTP.MetricsEnabled {
t.Error("metrics were not disabled")
}
}
// An unparseable value falls back to the default rather than to zero, which
// would silently disable a limit.
func TestUnparseableValuesFallBackToDefaults(t *testing.T) {
clearEnv(t)
t.Setenv("RELATA_WORKER_COUNT", "not-a-number")
t.Setenv("RELATA_SESSION_IDLE_TIMEOUT", "not-a-duration")
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if cfg.Workers.Count != 3 {
t.Errorf("worker count = %d, want the default 3", cfg.Workers.Count)
}
if cfg.Session.IdleTimeout != 12*time.Hour {
t.Errorf("idle timeout = %v, want the default 12h", cfg.Session.IdleTimeout)
}
}
func TestUploadDirectoryIsMadeAbsolute(t *testing.T) {
clearEnv(t)
t.Setenv("RELATA_UPLOAD_DIR", "./relative/path")
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if !filepath.IsAbs(cfg.Uploads.Dir) {
t.Fatalf("upload directory %q is relative; it must be resolved so the containment check is meaningful",
cfg.Uploads.Dir)
}
}
// An answer is generated inside a request, so a write timeout shorter than the
// analysis timeout closes the connection while the model is still working —
// a silent failure rather than a slow success.
func TestWriteTimeoutMustOutlastTheAnalysisTimeout(t *testing.T) {
clearEnv(t)
t.Setenv("RELATA_ANALYSIS_TIMEOUT", "300s")
t.Setenv("RELATA_HTTP_WRITE_TIMEOUT", "60s")
_, err := Load("")
if err == nil {
t.Fatal("a write timeout shorter than the analysis timeout was accepted")
}
if !strings.Contains(err.Error(), "cut off mid-response") {
t.Fatalf("the error does not explain the consequence: %v", err)
}
// The rule does not apply when answer generation is off.
clearEnv(t)
t.Setenv("RELATA_ANALYSIS_TIMEOUT", "300s")
t.Setenv("RELATA_HTTP_WRITE_TIMEOUT", "60s")
t.Setenv("RELATA_ANSWER_ENABLED", "false")
if _, err := Load(""); err != nil {
t.Fatalf("the rule was applied with answers disabled: %v", err)
}
}
// The shipped defaults must be a working configuration, not one that times out
// on the documented default model.
func TestShippedDefaultsSupportAnswerGeneration(t *testing.T) {
clearEnv(t)
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if cfg.HTTP.WriteTimeout <= cfg.Ollama.AnalysisTimeout {
t.Fatalf("default write timeout %s does not outlast the analysis timeout %s",
cfg.HTTP.WriteTimeout, cfg.Ollama.AnalysisTimeout)
}
// Measured: llama3.1:8b on CPU handles three passages in ~33s and exceeds
// 120s at six. The default must leave headroom for the default model.
if cfg.Search.AnswerMaxChunks > 8 {
t.Fatalf("default answer passages = %d; too many to complete with the default model on CPU",
cfg.Search.AnswerMaxChunks)
}
}