| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- 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)
- }
- }
|