247 lines
6.4 KiB
Go
247 lines
6.4 KiB
Go
package api_test
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"encoding/json"
|
||
|
|
"mime/multipart"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/gorilla/websocket"
|
||
|
|
|
||
|
|
"pargar/backend/internal/api"
|
||
|
|
"pargar/backend/internal/config"
|
||
|
|
"pargar/backend/internal/storage"
|
||
|
|
"pargar/backend/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestCaptchaAnswerNotLeakedWithoutExposeFlag(t *testing.T) {
|
||
|
|
e := setup(t)
|
||
|
|
cfg := config.Load()
|
||
|
|
cfg.DevMode = true
|
||
|
|
cfg.CaptchaExposeAnswer = false
|
||
|
|
st := store.New(e.pool)
|
||
|
|
srv := api.NewServer(cfg, st, e.rdb, storage.NewLocal(t.TempDir(), "http://localhost:8080"))
|
||
|
|
ts := httptest.NewServer(srv.Handler())
|
||
|
|
t.Cleanup(ts.Close)
|
||
|
|
|
||
|
|
req, _ := http.NewRequest("GET", ts.URL+"/api/auth/captcha", nil)
|
||
|
|
resp, err := http.DefaultClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("captcha: %v", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
var body map[string]any
|
||
|
|
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("captcha: %d %v", resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
if _, ok := body["answer"]; ok {
|
||
|
|
t.Fatalf("captcha answer must not be exposed when CAPTCHA_EXPOSE_ANSWER is off: %v", body)
|
||
|
|
}
|
||
|
|
if body["challengeId"] == "" || body["imageBase64"] == "" {
|
||
|
|
t.Fatalf("expected challenge fields: %v", body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestClientIPUsesLastForwardedHop(t *testing.T) {
|
||
|
|
e := setup(t)
|
||
|
|
prev := api.SetAPIGlobalLimitForTest(3)
|
||
|
|
t.Cleanup(func() { api.SetAPIGlobalLimitForTest(prev) })
|
||
|
|
|
||
|
|
// Spoofed leading IP must not create a separate bucket — last hop is authoritative.
|
||
|
|
spoofed := "198.51.100.1, 203.0.113.10"
|
||
|
|
hitLimit := false
|
||
|
|
for i := 0; i < 10; i++ {
|
||
|
|
req, _ := http.NewRequest("GET", e.server.URL+"/api/auth/register-status", nil)
|
||
|
|
req.Header.Set("X-Forwarded-For", spoofed)
|
||
|
|
resp, err := http.DefaultClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("request: %v", err)
|
||
|
|
}
|
||
|
|
_ = resp.Body.Close()
|
||
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||
|
|
hitLimit = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !hitLimit {
|
||
|
|
t.Fatal("expected rate limit on last XFF hop")
|
||
|
|
}
|
||
|
|
|
||
|
|
// X-Real-IP is preferred and should share the same client identity.
|
||
|
|
req, _ := http.NewRequest("GET", e.server.URL+"/api/auth/register-status", nil)
|
||
|
|
req.Header.Set("X-Real-IP", "203.0.113.10")
|
||
|
|
req.Header.Set("X-Forwarded-For", "198.51.100.99")
|
||
|
|
resp, err := http.DefaultClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("x-real-ip: %v", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
if resp.StatusCode != http.StatusTooManyRequests {
|
||
|
|
t.Fatalf("X-Real-IP should share last-hop budget, got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
|
||
|
|
code, body := e.doHeaders(t, "GET", "/api/auth/register-status", "", map[string]string{
|
||
|
|
"X-Forwarded-For": "203.0.113.77",
|
||
|
|
}, nil)
|
||
|
|
if code != http.StatusOK {
|
||
|
|
t.Fatalf("other client should succeed, got %d %v", code, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestWebSocketSubprotocolAuth(t *testing.T) {
|
||
|
|
e := setup(t)
|
||
|
|
token := login(t, e, bootstrapAdminEmail)
|
||
|
|
|
||
|
|
u, err := url.Parse(e.server.URL)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("parse: %v", err)
|
||
|
|
}
|
||
|
|
u.Scheme = "ws"
|
||
|
|
u.Path = "/api/ws"
|
||
|
|
|
||
|
|
dialer := websocket.Dialer{
|
||
|
|
HandshakeTimeout: 3 * time.Second,
|
||
|
|
Subprotocols: []string{"bearer", token},
|
||
|
|
}
|
||
|
|
conn, resp, err := dialer.Dial(u.String(), nil)
|
||
|
|
if err != nil {
|
||
|
|
status := 0
|
||
|
|
if resp != nil {
|
||
|
|
status = resp.StatusCode
|
||
|
|
}
|
||
|
|
t.Fatalf("subprotocol dial: status=%d err=%v", status, err)
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||
|
|
t.Fatalf("expected 101, got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
if conn.Subprotocol() != "bearer" {
|
||
|
|
t.Fatalf("expected negotiated bearer subprotocol, got %q", conn.Subprotocol())
|
||
|
|
}
|
||
|
|
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||
|
|
_, msg, err := conn.ReadMessage()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("read welcome: %v", err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(string(msg), `"connected"`) {
|
||
|
|
t.Fatalf("unexpected welcome: %s", msg)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestWebSocketQueryTokenRejected(t *testing.T) {
|
||
|
|
e := setup(t)
|
||
|
|
token := login(t, e, bootstrapAdminEmail)
|
||
|
|
|
||
|
|
u, err := url.Parse(e.server.URL)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("parse: %v", err)
|
||
|
|
}
|
||
|
|
u.Scheme = "ws"
|
||
|
|
u.Path = "/api/ws"
|
||
|
|
q := u.Query()
|
||
|
|
q.Set("token", token)
|
||
|
|
u.RawQuery = q.Encode()
|
||
|
|
|
||
|
|
dialer := websocket.Dialer{HandshakeTimeout: 3 * time.Second}
|
||
|
|
conn, resp, err := dialer.Dial(u.String(), nil)
|
||
|
|
if err == nil {
|
||
|
|
_ = conn.Close()
|
||
|
|
t.Fatal("query-string JWT must be rejected")
|
||
|
|
}
|
||
|
|
if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
status := 0
|
||
|
|
if resp != nil {
|
||
|
|
status = resp.StatusCode
|
||
|
|
}
|
||
|
|
t.Fatalf("legacy query dial: want 401 got status=%d err=%v", status, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestWebSocketHandshakeRateLimit(t *testing.T) {
|
||
|
|
e := setup(t)
|
||
|
|
token := login(t, e, bootstrapAdminEmail)
|
||
|
|
prev := api.SetWSHandshakeLimitForTest(2)
|
||
|
|
t.Cleanup(func() { api.SetWSHandshakeLimitForTest(prev) })
|
||
|
|
|
||
|
|
u, err := url.Parse(e.server.URL)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("parse: %v", err)
|
||
|
|
}
|
||
|
|
u.Scheme = "ws"
|
||
|
|
u.Path = "/api/ws"
|
||
|
|
|
||
|
|
header := http.Header{}
|
||
|
|
header.Set("X-Real-IP", "198.51.100.88")
|
||
|
|
dialer := websocket.Dialer{
|
||
|
|
HandshakeTimeout: 3 * time.Second,
|
||
|
|
Subprotocols: []string{"bearer", token},
|
||
|
|
}
|
||
|
|
|
||
|
|
var lastStatus int
|
||
|
|
for i := 0; i < 5; i++ {
|
||
|
|
conn, resp, err := dialer.Dial(u.String(), header)
|
||
|
|
if err == nil {
|
||
|
|
_ = conn.Close()
|
||
|
|
lastStatus = http.StatusSwitchingProtocols
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if resp != nil {
|
||
|
|
lastStatus = resp.StatusCode
|
||
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
t.Fatalf("expected WS handshake rate limit, last status=%d", lastStatus)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestChatUploadRateLimit(t *testing.T) {
|
||
|
|
e := setup(t)
|
||
|
|
token, _ := register(t, e, "Uploader", "uploader@test.dev")
|
||
|
|
|
||
|
|
hitLimit := false
|
||
|
|
for i := 0; i < 30; i++ {
|
||
|
|
code, body := postTinyUpload(t, e, token)
|
||
|
|
if code == http.StatusTooManyRequests {
|
||
|
|
hitLimit = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if code != http.StatusCreated {
|
||
|
|
t.Fatalf("upload %d: %d %v", i+1, code, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !hitLimit {
|
||
|
|
t.Fatal("expected per-user upload rate limit")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func postTinyUpload(t *testing.T, e *testEnv, token string) (int, map[string]any) {
|
||
|
|
t.Helper()
|
||
|
|
var buf bytes.Buffer
|
||
|
|
w := multipart.NewWriter(&buf)
|
||
|
|
part, err := w.CreateFormFile("file", "note.txt")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("form: %v", err)
|
||
|
|
}
|
||
|
|
_, _ = part.Write([]byte("hi"))
|
||
|
|
_ = w.Close()
|
||
|
|
|
||
|
|
req, _ := http.NewRequest("POST", e.server.URL+"/api/chats/upload", &buf)
|
||
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||
|
|
resp, err := http.DefaultClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("upload: %v", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
var body map[string]any
|
||
|
|
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||
|
|
return resp.StatusCode, body
|
||
|
|
}
|