2026-03-14 20:52:11 +01:00
|
|
|
package api_test
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-03 23:18:06 +02:00
|
|
|
"context"
|
2026-03-31 16:23:37 +02:00
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
2026-03-14 20:52:11 +01:00
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2026-03-15 07:07:59 +01:00
|
|
|
"strings"
|
2026-03-14 20:52:11 +01:00
|
|
|
"testing"
|
|
|
|
|
"testing/fstest"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/owncord/server/api"
|
|
|
|
|
"github.com/owncord/server/auth"
|
|
|
|
|
"github.com/owncord/server/db"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func newAPITestDB(t *testing.T) *db.DB {
|
|
|
|
|
t.Helper()
|
|
|
|
|
database, err := db.Open(":memory:")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("db.Open: %v", err)
|
|
|
|
|
}
|
2026-03-17 08:09:52 +01:00
|
|
|
t.Cleanup(func() { _ = database.Close() })
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
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("alice", "hash", 4)
|
|
|
|
|
token, _ := auth.GenerateToken()
|
|
|
|
|
hash := auth.HashToken(token)
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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_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("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")
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.Exec(
|
2026-03-14 20:52:11 +01:00
|
|
|
`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{
|
2026-03-29 21:31:18 +02:00
|
|
|
"Token abc", // wrong scheme
|
|
|
|
|
"Bearer", // missing token after Bearer
|
|
|
|
|
"abc", // no space
|
2026-03-14 20:52:11 +01:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── RequirePermission tests ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestRequirePermission_Allowed(t *testing.T) {
|
|
|
|
|
database := newAPITestDB(t)
|
2026-03-15 00:31:39 +01:00
|
|
|
uid, _ := database.CreateUser("carol", "hash", 4) // Member role = 0x663
|
2026-03-14 20:52:11 +01:00
|
|
|
token, _ := auth.GenerateToken()
|
|
|
|
|
hash := auth.HashToken(token)
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
// SEND_MESSAGES = 0x1 — Member role has this bit
|
|
|
|
|
h := api.AuthMiddleware(database)(
|
|
|
|
|
api.RequirePermission(0x1)(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)
|
2026-03-15 00:31:39 +01:00
|
|
|
uid, _ := database.CreateUser("dave", "hash", 4) // Member role = 0x663
|
2026-03-14 20:52:11 +01:00
|
|
|
token, _ := auth.GenerateToken()
|
|
|
|
|
hash := auth.HashToken(token)
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
// MANAGE_ROLES = 0x1000000 — Member does not have this
|
|
|
|
|
h := api.AuthMiddleware(database)(
|
|
|
|
|
api.RequirePermission(0x1000000)(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("owner", "hash", 1)
|
|
|
|
|
token, _ := auth.GenerateToken()
|
|
|
|
|
hash := auth.HashToken(token)
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
// Any permission should pass for ADMINISTRATOR
|
|
|
|
|
h := api.AuthMiddleware(database)(
|
|
|
|
|
api.RequirePermission(0x1000000)(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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── RateLimitMiddleware tests ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestRateLimitMiddleware_UnderLimit(t *testing.T) {
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
|
|
|
|
|
h := api.RateLimitMiddleware(limiter, 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, limit, time.Minute)(http.HandlerFunc(ok))
|
|
|
|
|
|
2026-03-17 04:11:04 +01:00
|
|
|
for range limit {
|
2026-03-14 20:52:11 +01:00
|
|
|
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, 1, time.Minute)(http.HandlerFunc(ok))
|
|
|
|
|
|
|
|
|
|
// Exhaust limit.
|
2026-03-17 04:11:04 +01:00
|
|
|
for range 2 {
|
2026-03-14 20:52:11 +01:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 07:07:59 +01:00
|
|
|
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.
|
2026-03-14 20:52:11 +01:00
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
limit := 2
|
|
|
|
|
|
|
|
|
|
h := api.RateLimitMiddleware(limiter, limit, time.Minute)(http.HandlerFunc(ok))
|
|
|
|
|
|
2026-03-15 07:07:59 +01:00
|
|
|
// Two requests from RemoteAddr 10.0.0.99 with an attacker-supplied X-Real-IP.
|
2026-03-17 04:11:04 +01:00
|
|
|
for range limit {
|
2026-03-14 20:52:11 +01:00
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
2026-03-15 07:07:59 +01:00
|
|
|
req.Header.Set("X-Real-IP", "192.168.1.1") // forged; must be ignored
|
2026-03-14 20:52:11 +01:00
|
|
|
req.RemoteAddr = "10.0.0.99:9999"
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rr, req)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 07:07:59 +01:00
|
|
|
// Third request from the same RemoteAddr should be blocked — rate key is
|
|
|
|
|
// 10.0.0.99, not the forged 192.168.1.1.
|
2026-03-14 20:52:11 +01:00
|
|
|
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 {
|
2026-03-15 07:07:59 +01:00
|
|
|
t.Errorf("RateLimitMiddleware no-trusted-proxy status = %d, want 429", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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, limit, time.Minute, trustedCIDRs)(http.HandlerFunc(ok))
|
|
|
|
|
|
|
|
|
|
// Two requests coming through trusted proxy 10.0.0.1, client IP 203.0.113.5.
|
2026-03-17 04:11:04 +01:00
|
|
|
for range limit {
|
2026-03-15 07:07:59 +01:00
|
|
|
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("banneduser", "hash", 4)
|
2026-03-17 08:09:52 +01:00
|
|
|
_ = database.BanUser(uid, "rule violation", nil) // permanent ban
|
2026-03-15 07:07:59 +01:00
|
|
|
token, _ := auth.GenerateToken()
|
|
|
|
|
hash := auth.HashToken(token)
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
|
2026-03-15 07:07:59 +01:00
|
|
|
|
|
|
|
|
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("expbanned", "hash", 4)
|
|
|
|
|
|
|
|
|
|
// Set ban with an expiry time in the past.
|
|
|
|
|
past := time.Now().UTC().Add(-time.Hour)
|
2026-03-17 08:09:52 +01:00
|
|
|
_ = database.BanUser(uid, "temp ban", &past)
|
2026-03-15 07:07:59 +01:00
|
|
|
|
|
|
|
|
token, _ := auth.GenerateToken()
|
|
|
|
|
hash := auth.HashToken(token)
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
|
2026-03-15 07:07:59 +01:00
|
|
|
|
|
|
|
|
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("tempbanned", "hash", 4)
|
|
|
|
|
|
|
|
|
|
// Set ban with an expiry time in the future.
|
|
|
|
|
future := time.Now().UTC().Add(time.Hour)
|
2026-03-17 08:09:52 +01:00
|
|
|
_ = database.BanUser(uid, "temp ban", &future)
|
2026-03-15 07:07:59 +01:00
|
|
|
|
|
|
|
|
token, _ := auth.GenerateToken()
|
|
|
|
|
hash := auth.HashToken(token)
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateSession(uid, hash, "test", "127.0.0.1")
|
2026-03-15 07:07:59 +01:00
|
|
|
|
|
|
|
|
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.
|
2026-03-29 21:31:18 +02:00
|
|
|
const limit = 10 // bytes
|
2026-03-15 07:07:59 +01:00
|
|
|
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)
|
2026-03-14 20:52:11 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 16:23:37 +02:00
|
|
|
// ─── AdminIPRestrict tests ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestAdminIPRestrict_AllowedCIDR(t *testing.T) {
|
2026-04-02 12:15:47 +02:00
|
|
|
h := api.AdminIPRestrict([]string{"127.0.0.0/8"}, nil)(http.HandlerFunc(ok))
|
2026-03-31 16:23:37 +02:00
|
|
|
|
|
|
|
|
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) {
|
2026-04-02 12:15:47 +02:00
|
|
|
h := api.AdminIPRestrict([]string{"10.0.0.0/8"}, nil)(http.HandlerFunc(ok))
|
2026-03-31 16:23:37 +02:00
|
|
|
|
|
|
|
|
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) {
|
2026-04-02 12:15:47 +02:00
|
|
|
h := api.AdminIPRestrict(nil, nil)(http.HandlerFunc(ok))
|
2026-03-31 16:23:37 +02:00
|
|
|
|
|
|
|
|
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 (deny access since isTrustedProxy
|
|
|
|
|
// returns false on parse error).
|
2026-04-02 12:15:47 +02:00
|
|
|
h := api.AdminIPRestrict([]string{"not-a-cidr"}, nil)(http.HandlerFunc(ok))
|
2026-03-31 16:23:37 +02:00
|
|
|
|
|
|
|
|
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) {
|
2026-04-02 12:15:47 +02:00
|
|
|
h := api.AdminIPRestrict([]string{"10.0.0.0/8", "192.168.0.0/16"}, nil)(http.HandlerFunc(ok))
|
2026-03-31 16:23:37 +02:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 12:15:47 +02:00
|
|
|
// ─── 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 16:23:37 +02:00
|
|
|
// ─── 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) {
|
2026-04-03 23:18:06 +02:00
|
|
|
h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
2026-03-31 16:23:37 +02:00
|
|
|
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) {
|
2026-04-03 23:18:06 +02:00
|
|
|
h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
2026-03-31 16:23:37 +02:00
|
|
|
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) {
|
2026-04-03 23:18:06 +02:00
|
|
|
h := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
2026-03-31 16:23:37 +02:00
|
|
|
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"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 20:52:11 +01:00
|
|
|
// 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),
|
2026-03-15 00:31:39 +01:00
|
|
|
(4, 'Member', NULL, 1635, 40, 1);
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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 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);
|
2026-03-29 21:31:18 +02:00
|
|
|
|
|
|
|
|
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');
|
2026-03-31 16:21:49 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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'))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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'))
|
|
|
|
|
);
|
2026-03-14 20:52:11 +01:00
|
|
|
`)
|