package server import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "vocat/internal/store" ) func TestParseAccessConfigValidation(t *testing.T) { if _, err := parseAccessConfig(accessConfig{Mode: "bogus"}); err == nil { t.Fatal("accepted an invalid mode") } if _, err := parseAccessConfig(accessConfig{Mode: "internal", AllowedCIDRs: []string{"not-a-cidr"}}); err == nil { t.Fatal("accepted an invalid CIDR") } parsed, err := parseAccessConfig(accessConfig{Mode: "internal", AllowedCIDRs: []string{"203.0.113.0/24", "198.51.100.7"}}) if err != nil { t.Fatalf("parseAccessConfig: %v", err) } if len(parsed.cidrs) != 2 { t.Fatalf("cidrs = %v", parsed.cidrs) } } func TestAccessControlMiddleware(t *testing.T) { server := &Server{logger: regionTestLogger()} ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) handler := server.accessControl(ok) check := func(config parsedAccessConfig, remoteAddr string, forwardedFor string) int { server.accessMu.Lock() server.access = config server.accessMu.Unlock() req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = remoteAddr if forwardedFor != "" { req.Header.Set("X-Forwarded-For", forwardedFor) } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) return recorder.Code } internal := parsedAccessConfig{mode: "internal"} if got := check(internal, "192.168.2.10:5000", ""); got != http.StatusOK { t.Fatalf("private IP denied: %d", got) } if got := check(internal, "127.0.0.1:5000", ""); got != http.StatusOK { t.Fatalf("loopback denied: %d", got) } if got := check(internal, "8.8.8.8:5000", ""); got != http.StatusForbidden { t.Fatalf("public IP allowed in internal mode: %d", got) } // Custom CIDR admits an otherwise-public range. withCIDR := parsedAccessConfig{mode: "internal"} parsed, _ := parseAccessConfig(accessConfig{Mode: "internal", AllowedCIDRs: []string{"8.8.8.0/24"}}) withCIDR = parsed if got := check(withCIDR, "8.8.8.8:5000", ""); got != http.StatusOK { t.Fatalf("custom CIDR not honored: %d", got) } // Public mode allows anything. public := parsedAccessConfig{mode: "public"} if got := check(public, "8.8.8.8:5000", ""); got != http.StatusOK { t.Fatalf("public mode denied a public IP: %d", got) } // Proxy headers are ignored unless explicitly trusted. trust := parsedAccessConfig{mode: "internal", trustProxy: true} if got := check(trust, "8.8.8.8:5000", "192.168.1.20"); got != http.StatusOK { t.Fatalf("trusted X-Forwarded-For not honored: %d", got) } if got := check(internal, "8.8.8.8:5000", "192.168.1.20"); got != http.StatusForbidden { t.Fatalf("untrusted X-Forwarded-For was honored: %d", got) } } func TestLoginRateLimiterLocksAndResets(t *testing.T) { limiter := newLoginRateLimiter() now := time.Now() limiter.now = func() time.Time { return now } key := "192.168.1.1|admin" for i := 0; i < limiter.maxFailures-1; i++ { if _, locked := limiter.recordFailure(key); locked { t.Fatalf("locked after %d failures, below threshold", i+1) } } if _, locked := limiter.recordFailure(key); !locked { t.Fatal("not locked at the failure threshold") } if _, locked := limiter.checkLocked(key); !locked { t.Fatal("checkLocked did not report the lock") } // Success clears the track record. limiter.recordSuccess(key) if _, locked := limiter.checkLocked(key); locked { t.Fatal("still locked after a success") } // Lockout expires after the lockout duration. for i := 0; i < limiter.maxFailures; i++ { limiter.recordFailure(key) } now = now.Add(limiter.lockout + time.Second) if _, locked := limiter.checkLocked(key); locked { t.Fatal("lock did not expire after the lockout window") } } func newSettingsTestServer(t *testing.T) *Server { t.Helper() database, err := store.Open(context.Background(), ":memory:") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = database.Close() }) return &Server{ store: database, logger: regionTestLogger(), maxRequestBodyBytes: 4096, access: defaultAccessConfig(), } } func TestHandleSecuritySettingsRoundTrip(t *testing.T) { server := newSettingsTestServer(t) body := `{"mode":"internal","allowed_cidrs":["203.0.113.0/24"],"trust_proxy_headers":true}` request := httptest.NewRequest(http.MethodPut, "/api/settings/security", strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") request.RemoteAddr = "192.168.2.20:5000" recorder := httptest.NewRecorder() server.handleSecuritySettings(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("PUT status = %d, body=%s", recorder.Code, recorder.Body.String()) } if server.currentAccessConfig().trustProxy != true { t.Fatal("runtime access config was not updated") } // Persisted? setting, err := server.store.AppSetting(context.Background(), accessSettingKey) if err != nil || !strings.Contains(string(setting.Value), "203.0.113.0/24") { t.Fatalf("access policy not persisted: %v %v", setting, err) } // GET reflects it. getRec := httptest.NewRecorder() getReq := httptest.NewRequest(http.MethodGet, "/api/settings/security", nil) getReq.RemoteAddr = "192.168.2.20:5000" server.handleSecuritySettings(getRec, getReq) var envelope struct { Data map[string]any `json:"data"` } if err := json.NewDecoder(getRec.Body).Decode(&envelope); err != nil { t.Fatal(err) } if envelope.Data["trust_proxy_headers"] != true || envelope.Data["client_allowed"] != true { t.Fatalf("GET data = %v", envelope.Data) } } func TestHandleSecuritySettingsRejectsBadPolicy(t *testing.T) { server := newSettingsTestServer(t) request := httptest.NewRequest(http.MethodPut, "/api/settings/security", strings.NewReader(`{"mode":"nowhere"}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() server.handleSecuritySettings(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", recorder.Code) } } func TestHandleLoggingSettingsRoundTripAndEnforceCount(t *testing.T) { server := newSettingsTestServer(t) // Seed 10 log rows. for i := 0; i < 10; i++ { if _, err := server.store.AppendLogEvent(context.Background(), store.LogEvent{ Level: "info", Message: "entry", }); err != nil { t.Fatal(err) } } // Keep only the newest 4. request := httptest.NewRequest(http.MethodPut, "/api/settings/logging", strings.NewReader(`{"mode":"count","count":4}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() server.handleLoggingSettings(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("PUT status = %d, body=%s", recorder.Code, recorder.Body.String()) } count, err := server.store.CountLogEvents(context.Background()) if err != nil { t.Fatal(err) } if count != 4 { t.Fatalf("stored log count = %d, want 4 after retention", count) } } func TestLoginLockoutViaHTTP(t *testing.T) { app := newTestApplication(t) for i := 0; i < 4; i++ { response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", strings.NewReader(`{"username":"admin","password":"wrong"}`)) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusUnauthorized { t.Fatalf("attempt %d status = %d, want 401", i+1, response.StatusCode) } } // Fifth consecutive failure crosses the threshold and locks. response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", strings.NewReader(`{"username":"admin","password":"wrong"}`)) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusTooManyRequests { t.Fatalf("5th failure status = %d, want 429", response.StatusCode) } // Even the correct password is refused while locked. response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json", strings.NewReader(`{"username":"admin","password":"correct-password"}`)) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusTooManyRequests { t.Fatalf("locked login status = %d, want 429", response.StatusCode) } }