403 lines
12 KiB
Go
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)
|
|
}
|
|
}
|