package api_test import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "testing" "testing/fstest" "time" "github.com/owncord/server/api" "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/permissions" ) // ─── Helpers ───────────────────────────────────────────────────────────────── func newAPITestDB(t *testing.T) *db.DB { t.Helper() database, err := db.Open(":memory:") if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { _ = database.Close() }) migrFS := fstest.MapFS{ "001_schema.sql": {Data: apiTestSchema}, } if err := db.MigrateFS(database, migrFS); err != nil { t.Fatalf("MigrateFS: %v", err) } return database } // ok is a trivial handler that responds 200 OK to confirm the middleware // passed the request through. func ok(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } // bearerToken wraps an HTTP handler with an Authorization header bearing token. func withBearer(req *http.Request, token string) *http.Request { req.Header.Set("Authorization", "Bearer "+token) return req } // ─── AuthMiddleware tests ───────────────────────────────────────────────────── func TestAuthMiddleware_ValidToken(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "alice", "hash", 4) token, _ := auth.GenerateToken() hash := auth.HashToken(token) _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("AuthMiddleware valid token status = %d, want %d", rr.Code, http.StatusOK) } } // TestAuthMiddleware_TouchSessionThrottled verifies the last_used write is // throttled per session: the first authenticated request touches the row, and // an immediate second request through the same middleware instance does not — // a hot session costs at most one write per interval instead of one per // request. func TestAuthMiddleware_TouchSessionThrottled(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "touchy", "hash", 4) token, _ := auth.GenerateToken() hash := auth.HashToken(token) _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) const sentinel = "2000-01-01 00:00:00" backdate := func() { t.Helper() if _, err := database.ExecContext(context.Background(), `UPDATE sessions SET last_used = ? WHERE token = ?`, sentinel, hash); err != nil { t.Fatalf("backdating last_used: %v", err) } } lastUsed := func() string { t.Helper() sess, err := database.GetSessionByTokenHash(context.Background(), hash) if err != nil || sess == nil { t.Fatalf("GetSessionByTokenHash: %v (sess=%v)", err, sess) } return sess.LastUsed } do := func() { t.Helper() rr := httptest.NewRecorder() h.ServeHTTP(rr, withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } } // First request: the session has never been touched by this middleware // instance, so last_used must be written. backdate() do() if lastUsed() == sentinel { t.Fatal("first request did not touch last_used") } // Second request inside the throttle interval: no write. backdate() do() if lastUsed() != sentinel { t.Error("second request within the throttle interval touched last_used; want it skipped") } } func TestAuthMiddleware_MissingToken(t *testing.T) { database := newAPITestDB(t) h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("AuthMiddleware no token status = %d, want 401", rr.Code) } } func TestAuthMiddleware_ValidAPIToken(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "botuser", "hash", 4) token, _ := auth.GenerateToken() if _, err := database.CreateAPIToken(context.Background(), uid, auth.HashToken(token), "ci", nil); err != nil { t.Fatalf("CreateAPIToken: %v", err) } var gotUserID int64 h := api.AuthMiddleware(database)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if u, ok := r.Context().Value(api.UserKey).(*db.User); ok && u != nil { gotUserID = u.ID } // An API-token principal has no login session: SessionKey must be nil. if s, ok := r.Context().Value(api.SessionKey).(*db.Session); ok && s != nil { t.Errorf("expected nil session for API-token principal, got %+v", s) } w.WriteHeader(http.StatusOK) })) req := withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("API token status = %d, want 200", rr.Code) } if gotUserID != uid { t.Errorf("API token authenticated as user %d, want %d", gotUserID, uid) } } func TestAuthMiddleware_RevokedAPIToken(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "botuser2", "hash", 4) token, _ := auth.GenerateToken() id, _ := database.CreateAPIToken(context.Background(), uid, auth.HashToken(token), "ci", nil) if _, err := database.RevokeAPIToken(context.Background(), id); err != nil { t.Fatalf("RevokeAPIToken: %v", err) } h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("revoked API token status = %d, want 401", rr.Code) } } func TestAuthMiddleware_InvalidToken(t *testing.T) { database := newAPITestDB(t) h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, "notarealtoken") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("AuthMiddleware invalid token status = %d, want 401", rr.Code) } } func TestAuthMiddleware_ExpiredSession(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "bob", "hash", 4) token, _ := auth.GenerateToken() hash := auth.HashToken(token) // Insert an already-expired session. pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05") _, _ = database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, uid, hash, "test", "127.0.0.1", pastTime, ) h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("AuthMiddleware expired session status = %d, want 401", rr.Code) } } func TestAuthMiddleware_MalformedAuthHeader(t *testing.T) { database := newAPITestDB(t) h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) cases := []string{ "Token abc", // wrong scheme "Bearer", // missing token after Bearer "abc", // no space } for _, header := range cases { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Authorization", header) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("AuthMiddleware header=%q status = %d, want 401", header, rr.Code) } } } // TestAuthMiddleware_DanglingRoleUnauthorized pins the `role == nil` guard: // GetRoleByID returns (nil, nil) for a role_id with no roles row, so without // the guard a nil role reached the request context and the request only died // later, at RequirePermission's own nil check (403) — or not at all on routes // that have no RequirePermission. func TestAuthMiddleware_DanglingRoleUnauthorized(t *testing.T) { database := newAPITestDB(t) // users.role_id has a FK to roles(id), so the dangling row can only be // created with FK enforcement momentarily off (db.Open pins the pool to a // single connection, so the pragma applies to the inserts that follow). if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil { t.Fatalf("disable foreign keys: %v", err) } res, err := database.ExecContext(context.Background(), `INSERT INTO users (username, password, role_id) VALUES ('dangling', '$2a$12$fake', 999)`) if err != nil { t.Fatalf("insert dangling user: %v", err) } uid, _ := res.LastInsertId() if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil { t.Fatalf("re-enable foreign keys: %v", err) } token, _ := auth.GenerateToken() if _, err := database.ExecContext(context.Background(), `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z')`, uid, auth.HashToken(token), ); err != nil { t.Fatalf("insert session: %v", err) } h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("AuthMiddleware dangling role status = %d, want 401", rr.Code) } } // ─── RequirePermission tests ────────────────────────────────────────────────── func TestRequirePermission_Allowed(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "carol", "hash", 4) // Member role = 0x663 token, _ := auth.GenerateToken() hash := auth.HashToken(token) _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)( api.RequirePermission(permissions.SendMessages)(http.HandlerFunc(ok)), ) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("RequirePermission allowed status = %d, want 200", rr.Code) } } func TestRequirePermission_Forbidden(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "dave", "hash", 4) // Member role = 0x663 token, _ := auth.GenerateToken() hash := auth.HashToken(token) _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)( api.RequirePermission(permissions.ManageRoles)(http.HandlerFunc(ok)), ) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("RequirePermission forbidden status = %d, want 403", rr.Code) } } func TestRequirePermission_Administrator_Bypass(t *testing.T) { database := newAPITestDB(t) // Owner role (id=1) has permissions 0x7FFFFFFF which includes ADMINISTRATOR (0x40000000) uid, _ := database.CreateUser(context.Background(), "owner", "hash", 1) token, _ := auth.GenerateToken() hash := auth.HashToken(token) _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") // Any permission should pass for ADMINISTRATOR h := api.AuthMiddleware(database)( api.RequirePermission(permissions.ManageRoles)(http.HandlerFunc(ok)), ) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("RequirePermission administrator bypass status = %d, want 200", rr.Code) } } // TestRequirePermission_MultiBitRequiresAllBits pins the one behaviour the // HasServerPerm consolidation changed: a multi-bit mask is ALL-of, not any-of. // The previous raw `role.Permissions&perm == 0` test returned 200 here because // Member holds SendMessages, which was enough to make the mask non-zero. func TestRequirePermission_MultiBitRequiresAllBits(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "multibit", "hash", 4) // Member role = 1635, has SendMessages, not ManageRoles token, _ := auth.GenerateToken() hash := auth.HashToken(token) _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)( api.RequirePermission(permissions.SendMessages | permissions.ManageRoles)(http.HandlerFunc(ok)), ) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("RequirePermission partial multi-bit mask status = %d, want 403", rr.Code) } } // ─── RateLimitMiddleware tests ──────────────────────────────────────────────── func TestRateLimitMiddleware_UnderLimit(t *testing.T) { limiter := auth.NewRateLimiter() h := api.RateLimitMiddleware(limiter, "test:", 5, time.Minute)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.1:1234" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("RateLimitMiddleware under limit status = %d, want 200", rr.Code) } } func TestRateLimitMiddleware_OverLimit(t *testing.T) { limiter := auth.NewRateLimiter() limit := 3 h := api.RateLimitMiddleware(limiter, "test:", limit, time.Minute)(http.HandlerFunc(ok)) for range limit { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.2:1234" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) } // This next request should be rate-limited. req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.2:1234" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusTooManyRequests { t.Errorf("RateLimitMiddleware over limit status = %d, want 429", rr.Code) } } func TestRateLimitMiddleware_RetryAfterHeader(t *testing.T) { limiter := auth.NewRateLimiter() h := api.RateLimitMiddleware(limiter, "test:", 1, time.Minute)(http.HandlerFunc(ok)) // Exhaust limit. for range 2 { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.3:1234" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) } req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.3:1234" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Header().Get("Retry-After") == "" { t.Error("RateLimitMiddleware: missing Retry-After header on 429 response") } } func TestRateLimitMiddleware_XRealIPIgnoredWithoutTrustedProxy(t *testing.T) { // Without trusted proxies configured, X-Real-IP must be ignored. // Each request with the same RemoteAddr host counts as the same IP regardless // of what the X-Real-IP header says. limiter := auth.NewRateLimiter() limit := 2 h := api.RateLimitMiddleware(limiter, "test:", limit, time.Minute)(http.HandlerFunc(ok)) // Two requests from RemoteAddr 10.0.0.99 with an attacker-supplied X-Real-IP. for range limit { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Real-IP", "192.168.1.1") // forged; must be ignored req.RemoteAddr = "10.0.0.99:9999" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) } // Third request from the same RemoteAddr should be blocked — rate key is // 10.0.0.99, not the forged 192.168.1.1. req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Real-IP", "192.168.1.1") req.RemoteAddr = "10.0.0.99:9999" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusTooManyRequests { t.Errorf("RateLimitMiddleware no-trusted-proxy status = %d, want 429", rr.Code) } } // lockedBuffer is a goroutine-safe writer for capturing log output. type lockedBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *lockedBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *lockedBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // TestRateLimitMiddleware_InvalidCIDRWarnsAtConstructionNotPerRequest locks // the W3-3a hoist: the trusted-proxy CIDR list is parsed once when the // middleware is constructed — warning about invalid entries there — never on // the per-request path. func TestRateLimitMiddleware_InvalidCIDRWarnsAtConstructionNotPerRequest(t *testing.T) { logBuf := &lockedBuffer{} prev := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(logBuf, nil))) defer slog.SetDefault(prev) limiter := auth.NewRateLimiter() h := api.RateLimitMiddleware(limiter, "test:", 100, time.Minute, []string{"not-a-cidr", "10.0.0.0/8"})(http.HandlerFunc(ok)) const warnMsg = "ignoring invalid CIDR entry" if got := strings.Count(logBuf.String(), warnMsg); got != 1 { t.Fatalf("invalid-CIDR warnings at construction = %d, want 1 (log: %q)", got, logBuf.String()) } // The valid entry still works: X-Real-IP honoured from the trusted proxy. for range 3 { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.1:9999" req.Header.Set("X-Real-IP", "203.0.113.77") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("request status = %d, want 200", rr.Code) } } if got := strings.Count(logBuf.String(), warnMsg); got != 1 { t.Fatalf("invalid-CIDR warnings after 3 requests = %d, want 1 — CIDRs re-parsed on the request path (log: %q)", got, logBuf.String()) } } func TestRateLimitMiddleware_XRealIPHonouredFromTrustedProxy(t *testing.T) { // With a trusted proxy configured, X-Real-IP from that proxy is used. limiter := auth.NewRateLimiter() limit := 2 trustedCIDRs := []string{"10.0.0.0/8"} h := api.RateLimitMiddleware(limiter, "test:", limit, time.Minute, trustedCIDRs)(http.HandlerFunc(ok)) // Two requests coming through trusted proxy 10.0.0.1, client IP 203.0.113.5. for range limit { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Real-IP", "203.0.113.5") req.RemoteAddr = "10.0.0.1:9999" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) } // Third request with same X-Real-IP from same trusted proxy — should be blocked. req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Real-IP", "203.0.113.5") req.RemoteAddr = "10.0.0.1:9999" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusTooManyRequests { t.Errorf("RateLimitMiddleware trusted proxy X-Real-IP status = %d, want 429", rr.Code) } } // ─── Fix 2.10: Ban expiry in AuthMiddleware ─────────────────────────────────── // TestAuthMiddleware_BannedUserBlocked verifies that an actively banned user // with no expiry cannot pass the auth middleware. func TestAuthMiddleware_BannedUserBlocked(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "banneduser", "hash", 4) _ = database.BanUser(context.Background(), uid, "rule violation", nil) // permanent ban token, _ := auth.GenerateToken() hash := auth.HashToken(token) _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("AuthMiddleware banned user status = %d, want 403", rr.Code) } } // TestAuthMiddleware_ExpiredBanAllowed verifies that a user whose ban has // expired in the past can pass the auth middleware. func TestAuthMiddleware_ExpiredBanAllowed(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "expbanned", "hash", 4) // Set ban with an expiry time in the past. past := time.Now().UTC().Add(-time.Hour) _ = database.BanUser(context.Background(), uid, "temp ban", &past) token, _ := auth.GenerateToken() hash := auth.HashToken(token) _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("AuthMiddleware expired-ban user status = %d, want 200", rr.Code) } } // TestAuthMiddleware_ActiveTemporaryBanBlocked verifies that a user with a // temporary ban whose expiry is in the future is still blocked. func TestAuthMiddleware_ActiveTemporaryBanBlocked(t *testing.T) { database := newAPITestDB(t) uid, _ := database.CreateUser(context.Background(), "tempbanned", "hash", 4) // Set ban with an expiry time in the future. future := time.Now().UTC().Add(time.Hour) _ = database.BanUser(context.Background(), uid, "temp ban", &future) token, _ := auth.GenerateToken() hash := auth.HashToken(token) _, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1") h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) withBearer(req, token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("AuthMiddleware active temp-ban user status = %d, want 403", rr.Code) } } // ─── SecurityHeaders tests ─────────────────────────────────────────────────── func TestSecurityHeaders_AllHeadersPresent(t *testing.T) { h := api.SecurityHeaders(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) want := map[string]string{ "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "X-Xss-Protection": "0", "Referrer-Policy": "strict-origin-when-cross-origin", "Content-Security-Policy": "default-src 'self'", "Permissions-Policy": "camera=(), microphone=(), geolocation=()", "Cache-Control": "no-store", } for header, expected := range want { if got := rr.Header().Get(header); got != expected { t.Errorf("SecurityHeaders: %s = %q, want %q", header, got, expected) } } } func TestSecurityHeaders_PassesThrough(t *testing.T) { // Middleware must not swallow the response — downstream handler must be called. called := false h := api.SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true w.WriteHeader(http.StatusTeapot) })) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if !called { t.Error("SecurityHeaders: downstream handler was not called") } if rr.Code != http.StatusTeapot { t.Errorf("SecurityHeaders: status = %d, want 418", rr.Code) } } func TestSecurityHeaders_DoesNotOverrideExistingHeaders(t *testing.T) { // If a downstream handler sets its own CSP, SecurityHeaders should not clobber it // because it runs before the handler writes. The middleware sets headers first, // the handler can then override them — that is the correct layering. // This test just confirms the middleware itself sets all seven headers. h := api.SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Handler overrides CSP after SecurityHeaders has already set it. w.Header().Set("Content-Security-Policy", "default-src 'none'") w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) // The handler's override wins because it runs after the middleware sets the header. if got := rr.Header().Get("Content-Security-Policy"); got != "default-src 'none'" { t.Errorf("SecurityHeaders: handler CSP override = %q, want \"default-src 'none'\"", got) } } // ─── MaxBodySize tests ──────────────────────────────────────────────────────── func TestMaxBodySize_UnderLimit(t *testing.T) { // A body smaller than the limit must be read successfully by the handler. const limit = 10 // bytes body := strings.NewReader("hello") // 5 bytes — under limit h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { data := make([]byte, 20) n, _ := r.Body.Read(data) if n != 5 { t.Errorf("MaxBodySize under limit: read %d bytes, want 5", n) } w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodPost, "/", body) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("MaxBodySize under limit: status = %d, want 200", rr.Code) } } func TestMaxBodySize_ExactLimit(t *testing.T) { // A body exactly at the limit must be read without error. const limit = 5 body := strings.NewReader("hello") // exactly 5 bytes h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { data := make([]byte, 10) n, _ := r.Body.Read(data) if n != 5 { t.Errorf("MaxBodySize exact limit: read %d bytes, want 5", n) } w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodPost, "/", body) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("MaxBodySize exact limit: status = %d, want 200", rr.Code) } } func TestMaxBodySize_OverLimit(t *testing.T) { // Reading beyond the limit must return an error from MaxBytesReader. const limit = 5 body := strings.NewReader("hello world") // 11 bytes — over limit var readErr error h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { data := make([]byte, 20) _, readErr = r.Body.Read(data) w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodPost, "/", body) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if readErr == nil { t.Error("MaxBodySize over limit: expected read error, got nil") } } func TestMaxBodySize_NilBody(t *testing.T) { // GET requests with no body must pass through without panic. h := api.MaxBodySize(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() // Must not panic. h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("MaxBodySize nil body: status = %d, want 200", rr.Code) } } func TestMaxBodySize_PassesThrough(t *testing.T) { // Downstream handler must be called and its status code preserved. h := api.MaxBodySize(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) })) req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("data")) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusCreated { t.Errorf("MaxBodySize pass-through: status = %d, want 201", rr.Code) } } // ─── AdminIPRestrict tests ────────────────────────────────────────────────── func TestAdminIPRestrict_AllowedCIDR(t *testing.T) { h := api.AdminIPRestrict([]string{"127.0.0.0/8"}, nil)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "127.0.0.1:9999" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("AdminIPRestrict allowed CIDR status = %d, want 200", rr.Code) } } func TestAdminIPRestrict_BlockedCIDR(t *testing.T) { h := api.AdminIPRestrict([]string{"10.0.0.0/8"}, nil)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "192.168.1.1:9999" // not in 10.0.0.0/8 rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("AdminIPRestrict blocked CIDR status = %d, want 403", rr.Code) } } func TestAdminIPRestrict_EmptyAllowsAll(t *testing.T) { h := api.AdminIPRestrict(nil, nil)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "203.0.113.1:9999" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("AdminIPRestrict empty list status = %d, want 200", rr.Code) } } func TestAdminIPRestrict_InvalidCIDR(t *testing.T) { // Invalid CIDR should fail closed: the entry is skipped at construction, // leaving a non-empty allowed list with zero parsed networks — nothing // matches, so access is denied. h := api.AdminIPRestrict([]string{"not-a-cidr"}, nil)(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "127.0.0.1:9999" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("AdminIPRestrict invalid CIDR status = %d, want 403", rr.Code) } } func TestAdminIPRestrict_MultipleCIDRs(t *testing.T) { h := api.AdminIPRestrict([]string{"10.0.0.0/8", "192.168.0.0/16"}, nil)(http.HandlerFunc(ok)) // First CIDR matches. req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.1.2.3:9999" rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("AdminIPRestrict multi-CIDR (10.x) status = %d, want 200", rr.Code) } // Second CIDR matches. req = httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "192.168.1.50:9999" rr = httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("AdminIPRestrict multi-CIDR (192.168.x) status = %d, want 200", rr.Code) } // Neither matches. req = httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "172.16.0.1:9999" rr = httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("AdminIPRestrict multi-CIDR (no match) status = %d, want 403", rr.Code) } } // ─── AdminIPRestrict proxy-aware tests (BUG-116) ───────────────────────────── // TestAdminIPRestrict_TrustedProxy_UsesXForwardedFor verifies that when the // connecting IP is a trusted proxy, the real client IP is extracted from // X-Forwarded-For and checked against admin CIDRs. func TestAdminIPRestrict_TrustedProxy_UsesXForwardedFor(t *testing.T) { // Admin allowed: only 203.0.113.0/24. Trusted proxy: 127.0.0.1. h := api.AdminIPRestrict( []string{"203.0.113.0/24"}, []string{"127.0.0.0/8"}, )(http.HandlerFunc(ok)) // Request from proxy (127.0.0.1) with real client in XFF → allowed. req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "127.0.0.1:9999" req.Header.Set("X-Forwarded-For", "203.0.113.50") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("trusted proxy + allowed XFF status = %d, want 200", rr.Code) } // Request from proxy with disallowed real client → blocked. req = httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "127.0.0.1:9999" req.Header.Set("X-Forwarded-For", "198.51.100.1") rr = httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("trusted proxy + blocked XFF status = %d, want 403", rr.Code) } } // TestAdminIPRestrict_TrustedProxy_UsesXRealIP verifies X-Real-IP is preferred // over X-Forwarded-For when both are present from a trusted proxy. func TestAdminIPRestrict_TrustedProxy_UsesXRealIP(t *testing.T) { h := api.AdminIPRestrict( []string{"203.0.113.0/24"}, []string{"127.0.0.0/8"}, )(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "127.0.0.1:9999" req.Header.Set("X-Real-IP", "203.0.113.50") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("trusted proxy + X-Real-IP status = %d, want 200", rr.Code) } } // TestAdminIPRestrict_UntrustedProxy_IgnoresHeaders verifies that proxy headers // are ignored when the connecting IP is NOT a trusted proxy. func TestAdminIPRestrict_UntrustedProxy_IgnoresHeaders(t *testing.T) { h := api.AdminIPRestrict( []string{"203.0.113.0/24"}, []string{"10.0.0.0/8"}, // only 10.x is trusted )(http.HandlerFunc(ok)) // Untrusted proxy at 192.168.1.1 tries to spoof XFF. req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "192.168.1.1:9999" req.Header.Set("X-Forwarded-For", "203.0.113.50") // spoofed rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("untrusted proxy spoofed XFF status = %d, want 403 (should use RemoteAddr)", rr.Code) } } // TestAdminIPRestrict_ProxyCollapse_WithoutTrusted verifies the original bug: // without trusted proxies, a proxy on localhost makes everything appear local. func TestAdminIPRestrict_ProxyCollapse_WithoutTrusted(t *testing.T) { // Admin CIDR: private networks. No trusted proxies. h := api.AdminIPRestrict( []string{"127.0.0.0/8", "10.0.0.0/8"}, nil, // no trusted proxies )(http.HandlerFunc(ok)) // External client behind nginx on localhost — RemoteAddr is 127.0.0.1. // XFF has the real external IP, but it's ignored (no trusted proxies). req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "127.0.0.1:9999" req.Header.Set("X-Forwarded-For", "198.51.100.1") // real external IP rr := httptest.NewRecorder() h.ServeHTTP(rr, req) // Without trusted proxies, 127.0.0.1 is used — passes the private CIDR check. // This is the documented limitation: operators MUST configure trusted_proxies. if rr.Code != http.StatusOK { t.Errorf("no trusted proxies, proxy on localhost status = %d, want 200 (known limitation)", rr.Code) } } // ─── SecurityHeadersWithTLS tests ─────────────────────────────────────────── func TestSecurityHeadersWithTLS_HSTS(t *testing.T) { h := api.SecurityHeadersWithTLS("auto")(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if got := rr.Header().Get("Strict-Transport-Security"); got == "" { t.Error("SecurityHeadersWithTLS: missing HSTS header when TLS enabled") } } func TestSecurityHeadersWithTLS_NoHSTSWithoutTLS(t *testing.T) { h := api.SecurityHeadersWithTLS("")(http.HandlerFunc(ok)) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if got := rr.Header().Get("Strict-Transport-Security"); got != "" { t.Errorf("SecurityHeadersWithTLS: unexpected HSTS header %q when TLS disabled", got) } } // ─── handleLiveKitHealth tests ────────────────────────────────────────────── func TestLiveKitHealth_Healthy(t *testing.T) { h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) { return true, nil }) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) if resp["status"] != "ok" { t.Errorf("status = %v, want ok", resp["status"]) } if resp["livekit_reachable"] != true { t.Errorf("livekit_reachable = %v, want true", resp["livekit_reachable"]) } } func TestLiveKitHealth_Unhealthy(t *testing.T) { h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) { return false, fmt.Errorf("connection refused") }) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503; body: %s", rr.Code, rr.Body.String()) } var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) if resp["status"] != "degraded" { t.Errorf("status = %v, want degraded", resp["status"]) } if resp["livekit_reachable"] != false { t.Errorf("livekit_reachable = %v, want false", resp["livekit_reachable"]) } if resp["error"] != "connection refused" { t.Errorf("error = %v, want 'connection refused'", resp["error"]) } } func TestLiveKitHealth_UnhealthyNoError(t *testing.T) { h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) { return false, nil }) req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503; body: %s", rr.Code, rr.Body.String()) } var resp map[string]any _ = json.NewDecoder(rr.Body).Decode(&resp) if resp["error"] != "unknown" { t.Errorf("error = %v, want 'unknown'", resp["error"]) } } // apiTestSchema is the full schema needed for all api tests (middleware, // auth handler, and invite handler). var apiTestSchema = []byte(` CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, color TEXT, permissions INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0 ); INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES (1, 'Owner', '#E74C3C', 2147483647, 100, 0), (2, 'Admin', '#F39C12', 1073741823, 80, 0), (3, 'Moderator', '#3498DB', 1048575, 60, 0), (4, 'Member', NULL, 1635, 40, 1); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password TEXT NOT NULL, avatar TEXT, role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), totp_secret TEXT, status TEXT NOT NULL DEFAULT 'offline', created_at TEXT NOT NULL DEFAULT (datetime('now')), last_seen TEXT, banned INTEGER NOT NULL DEFAULT 0, ban_reason TEXT, ban_expires TEXT, identity_public_key TEXT, display_name TEXT, about TEXT, custom_status TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, device TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, label TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used_at TEXT, expires_at TEXT, revoked_at TEXT ); CREATE TABLE IF NOT EXISTS invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, created_by INTEGER NOT NULL REFERENCES users(id), redeemed_by INTEGER REFERENCES users(id), max_uses INTEGER, use_count INTEGER NOT NULL DEFAULT 0, expires_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), revoked INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); INSERT OR IGNORE INTO settings (key, value) VALUES ('require_2fa', 'false'), ('registration_open', 'true'); CREATE TABLE IF NOT EXISTS channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'text', category TEXT, topic TEXT, position INTEGER NOT NULL DEFAULT 0, slow_mode INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), voice_max_users INTEGER NOT NULL DEFAULT 0, voice_quality TEXT, mixing_threshold INTEGER, voice_max_video INTEGER NOT NULL DEFAULT 0, nsfw INTEGER NOT NULL DEFAULT 0, is_group INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channel_overrides ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, allow INTEGER NOT NULL DEFAULT 0, deny INTEGER NOT NULL DEFAULT 0, UNIQUE(channel_id, role_id) ); CREATE TABLE IF NOT EXISTS channel_user_overrides ( channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, allow INTEGER NOT NULL DEFAULT 0, deny INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (channel_id, user_id) ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id), content TEXT NOT NULL, reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, edited_at TEXT, deleted INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, timestamp TEXT NOT NULL DEFAULT (datetime('now')), mentions_everyone INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS message_mentions ( message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (message_id, mentioned_user_id) ); CREATE TABLE IF NOT EXISTS dm_participants ( channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (channel_id, user_id) ); CREATE TABLE IF NOT EXISTS dm_open_state ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, opened_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, channel_id) ); CREATE TABLE IF NOT EXISTS reactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, emoji TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(message_id, user_id, emoji) ); CREATE TABLE IF NOT EXISTS read_states ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, last_message_id INTEGER NOT NULL DEFAULT 0, mention_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, channel_id) ); CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id INTEGER NOT NULL REFERENCES users(id), action TEXT NOT NULL, target_type TEXT NOT NULL DEFAULT '', target_id INTEGER NOT NULL DEFAULT 0, detail TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); `)