Code sync, security fixes
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user