296 lines
8.3 KiB
Go
296 lines
8.3 KiB
Go
package api_test
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"io/fs"
|
||
|
|
"log"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
"github.com/redis/go-redis/v9"
|
||
|
|
|
||
|
|
"pargar/backend/internal/api"
|
||
|
|
"pargar/backend/internal/config"
|
||
|
|
"pargar/backend/internal/db"
|
||
|
|
"pargar/backend/internal/redisdb"
|
||
|
|
"pargar/backend/internal/seed"
|
||
|
|
"pargar/backend/internal/storage"
|
||
|
|
"pargar/backend/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
adminURL = testDatabaseURL("postgres")
|
||
|
|
testURL = testDatabaseURL("pargar_test")
|
||
|
|
redisURL = testRedisURL()
|
||
|
|
integrationOK bool
|
||
|
|
)
|
||
|
|
|
||
|
|
func testRedisURL() string {
|
||
|
|
if v := os.Getenv("REDIS_URL"); v != "" {
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
return "redis://localhost:6379"
|
||
|
|
}
|
||
|
|
|
||
|
|
func testDatabaseURL(database string) string {
|
||
|
|
user := os.Getenv("POSTGRES_USER")
|
||
|
|
if user == "" {
|
||
|
|
user = "pargar"
|
||
|
|
}
|
||
|
|
password := os.Getenv("POSTGRES_PASSWORD")
|
||
|
|
if password == "" {
|
||
|
|
password = "pargar"
|
||
|
|
}
|
||
|
|
return "postgres://" + user + ":" + password + "@localhost:5432/" + database + "?sslmode=disable"
|
||
|
|
}
|
||
|
|
|
||
|
|
type testEnv struct {
|
||
|
|
server *httptest.Server
|
||
|
|
pool *pgxpool.Pool
|
||
|
|
rdb *redis.Client
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMain(m *testing.M) {
|
||
|
|
_ = os.Setenv("ADMIN_PASSWORD", "password123")
|
||
|
|
// Local integration tests need captcha plaintext; production never sets this.
|
||
|
|
_ = os.Setenv("CAPTCHA_EXPOSE_ANSWER", "true")
|
||
|
|
_ = os.Setenv("DEV_MODE", "true")
|
||
|
|
ctx := context.Background()
|
||
|
|
root, err := pgxpool.New(ctx, adminURL)
|
||
|
|
if err != nil {
|
||
|
|
log.Printf("api integration tests disabled: %v", err)
|
||
|
|
os.Exit(m.Run())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_, _ = root.Exec(ctx, `DROP DATABASE IF EXISTS pargar_test`)
|
||
|
|
if _, err := root.Exec(ctx, `CREATE DATABASE pargar_test`); err != nil {
|
||
|
|
root.Close()
|
||
|
|
log.Printf("api integration tests disabled: %v", err)
|
||
|
|
os.Exit(m.Run())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
root.Close()
|
||
|
|
integrationOK = true
|
||
|
|
os.Exit(m.Run())
|
||
|
|
}
|
||
|
|
|
||
|
|
func setup(t *testing.T) *testEnv {
|
||
|
|
t.Helper()
|
||
|
|
if !integrationOK {
|
||
|
|
t.Skip("postgres unavailable")
|
||
|
|
}
|
||
|
|
ctx := context.Background()
|
||
|
|
pool, err := db.Connect(ctx, testURL)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("connect: %v", err)
|
||
|
|
}
|
||
|
|
sub, _ := fs.Sub(db.MigrationFiles, "migrations")
|
||
|
|
if err := db.Migrate(ctx, pool, sub); err != nil {
|
||
|
|
t.Fatalf("migrate: %v", err)
|
||
|
|
}
|
||
|
|
rdb, err := redisdb.Connect(ctx, redisURL)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("redis: %v", err)
|
||
|
|
}
|
||
|
|
if err := rdb.RDB().FlushDB(ctx).Err(); err != nil {
|
||
|
|
t.Fatalf("flush redis: %v", err)
|
||
|
|
}
|
||
|
|
st := store.New(pool)
|
||
|
|
if err := seed.Run(ctx, st, true); err != nil {
|
||
|
|
t.Fatalf("seed: %v", err)
|
||
|
|
}
|
||
|
|
cfg := config.Load()
|
||
|
|
srv := api.NewServer(cfg, st, rdb.RDB(), storage.NewLocal(t.TempDir(), "http://localhost:8080"))
|
||
|
|
ts := httptest.NewServer(srv.Handler())
|
||
|
|
t.Cleanup(func() {
|
||
|
|
ts.Close()
|
||
|
|
rdb.RDB().Close()
|
||
|
|
_, _ = pool.Exec(ctx, `TRUNCATE users, lessons, chapters, mcq_questions, watch_heartbeats,
|
||
|
|
watch_sessions, lesson_progress, quiz_attempts, xp_events, weekly_leaderboard, events,
|
||
|
|
event_rsvps, challenges, challenge_progress, chat_messages, notifications, notification_prefs, video_assets,
|
||
|
|
refresh_tokens, reminders_sent, certificates, certificate_physical_orders,
|
||
|
|
unlock_requests, registration_ips, registration_invites, registration_phone_whitelist,
|
||
|
|
registration_phone_blacklist, registration_blacklist_attempts, chat_export_tokens
|
||
|
|
RESTART IDENTITY CASCADE`)
|
||
|
|
_, _ = pool.Exec(ctx, `UPDATE app_settings SET value='false' WHERE key='registration_enabled'`)
|
||
|
|
pool.Close()
|
||
|
|
})
|
||
|
|
return &testEnv{server: ts, pool: pool, rdb: rdb.RDB()}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *testEnv) do(t *testing.T, method, path, token string, body any) (int, map[string]any) {
|
||
|
|
t.Helper()
|
||
|
|
return e.doHeaders(t, method, path, token, nil, body)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *testEnv) doHeaders(t *testing.T, method, path, token string, headers map[string]string, body any) (int, map[string]any) {
|
||
|
|
t.Helper()
|
||
|
|
var buf bytes.Buffer
|
||
|
|
if body != nil {
|
||
|
|
_ = json.NewEncoder(&buf).Encode(body)
|
||
|
|
}
|
||
|
|
req, _ := http.NewRequest(method, e.server.URL+path, &buf)
|
||
|
|
if token != "" {
|
||
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
for k, v := range headers {
|
||
|
|
req.Header.Set(k, v)
|
||
|
|
}
|
||
|
|
resp, err := http.DefaultClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
var out map[string]any
|
||
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||
|
|
return resp.StatusCode, out
|
||
|
|
}
|
||
|
|
|
||
|
|
const bootstrapAdminEmail = "admin@example.com"
|
||
|
|
|
||
|
|
// register creates a fresh student via the admin-only endpoint, then logs in.
|
||
|
|
func register(t *testing.T, e *testEnv, name, email string) (string, int64) {
|
||
|
|
t.Helper()
|
||
|
|
admin := login(t, e, bootstrapAdminEmail)
|
||
|
|
username := strings.Split(email, "@")[0]
|
||
|
|
code, body := e.do(t, "POST", "/api/admin/users", admin, map[string]any{
|
||
|
|
"name": name, "email": email, "username": username, "password": "password123", "role": "student",
|
||
|
|
"phone": "", "securityQuestion": "نام حیوان خانگی؟", "securityAnswer": "rex",
|
||
|
|
})
|
||
|
|
if code != http.StatusCreated {
|
||
|
|
t.Fatalf("admin create user: %d %v", code, body)
|
||
|
|
}
|
||
|
|
uid := int64(body["user"].(map[string]any)["id"].(float64))
|
||
|
|
token := login(t, e, email)
|
||
|
|
return token, uid
|
||
|
|
}
|
||
|
|
|
||
|
|
func fetchCaptcha(t *testing.T, e *testEnv) (challengeID, answer string) {
|
||
|
|
t.Helper()
|
||
|
|
code, body := e.do(t, "GET", "/api/auth/captcha", "", nil)
|
||
|
|
if code != http.StatusOK {
|
||
|
|
t.Fatalf("captcha: %d %v", code, body)
|
||
|
|
}
|
||
|
|
id, _ := body["challengeId"].(string)
|
||
|
|
img, _ := body["imageBase64"].(string)
|
||
|
|
answer, _ = body["answer"].(string)
|
||
|
|
if id == "" || img == "" || answer == "" {
|
||
|
|
t.Fatalf("captcha missing fields: %v", body)
|
||
|
|
}
|
||
|
|
return id, answer
|
||
|
|
}
|
||
|
|
|
||
|
|
func loginBody(t *testing.T, e *testEnv, identity, password string) map[string]any {
|
||
|
|
t.Helper()
|
||
|
|
id, answer := fetchCaptcha(t, e)
|
||
|
|
body := map[string]any{
|
||
|
|
"password": password,
|
||
|
|
"challengeId": id,
|
||
|
|
"captchaAnswer": answer,
|
||
|
|
}
|
||
|
|
if strings.Contains(identity, "@") {
|
||
|
|
body["email"] = identity
|
||
|
|
} else {
|
||
|
|
body["username"] = identity
|
||
|
|
}
|
||
|
|
return body
|
||
|
|
}
|
||
|
|
|
||
|
|
func login(t *testing.T, e *testEnv, email string) string {
|
||
|
|
t.Helper()
|
||
|
|
code, body := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, email, "password123"))
|
||
|
|
if code != http.StatusOK {
|
||
|
|
t.Fatalf("login %s: %d %v", email, code, body)
|
||
|
|
}
|
||
|
|
return body["accessToken"].(string)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestAuthRateLimit(t *testing.T) {
|
||
|
|
e := setup(t)
|
||
|
|
hitLimit := false
|
||
|
|
for i := 0; i < 40; i++ {
|
||
|
|
code, body := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "missing@test.dev", "wrong"))
|
||
|
|
if code == http.StatusTooManyRequests {
|
||
|
|
if body["error"] != "تعداد تلاشها بیش از حد مجاز است؛ کمی بعد دوباره تلاش کنید" {
|
||
|
|
t.Fatalf("unexpected rate limit message: %v", body)
|
||
|
|
}
|
||
|
|
hitLimit = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if code != http.StatusUnauthorized {
|
||
|
|
t.Fatalf("request %d expected unauthorized or rate limit, got %d %v", i+1, code, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !hitLimit {
|
||
|
|
t.Fatal("expected adaptive rate limit after repeated failed logins")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// watchToUnlock pumps heartbeats until the quiz unlocks.
|
||
|
|
// Wall-clock anti-cheat caps delta by real elapsed time, so each pulse first
|
||
|
|
// rewinds the session heartbeat timestamp (tests only).
|
||
|
|
func watchToUnlock(t *testing.T, e *testEnv, token string, lesson int64) {
|
||
|
|
t.Helper()
|
||
|
|
for i := 1; i <= 6; i++ {
|
||
|
|
rewindWatchSession(t, e, token, lesson)
|
||
|
|
code, body := e.do(t, "POST", "/api/lessons/"+itoa(lesson)+"/heartbeat", token,
|
||
|
|
map[string]any{"position": float64(i * 9), "delta": 9, "seq": i})
|
||
|
|
if code != http.StatusOK {
|
||
|
|
t.Fatalf("heartbeat: %d %v", code, body)
|
||
|
|
}
|
||
|
|
if body["quizUnlocked"] == true {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func rewindWatchSession(t *testing.T, e *testEnv, token string, lesson int64) {
|
||
|
|
t.Helper()
|
||
|
|
code, me := e.do(t, "GET", "/api/auth/me", token, nil)
|
||
|
|
if code != http.StatusOK {
|
||
|
|
t.Fatalf("auth/me for rewind: %d %v", code, me)
|
||
|
|
}
|
||
|
|
u, _ := me["user"].(map[string]any)
|
||
|
|
id, _ := u["id"].(float64)
|
||
|
|
if id < 1 {
|
||
|
|
t.Fatalf("auth/me missing user id: %v", me)
|
||
|
|
}
|
||
|
|
_, err := e.pool.Exec(context.Background(), `
|
||
|
|
INSERT INTO watch_sessions (user_id, lesson_id, started_at, last_heartbeat_at, last_position)
|
||
|
|
VALUES ($1, $2, now() - interval '30 seconds', now() - interval '30 seconds', 0)
|
||
|
|
ON CONFLICT (user_id, lesson_id) DO UPDATE
|
||
|
|
SET last_heartbeat_at = now() - interval '30 seconds'`, int64(id), lesson)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("rewind session: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func itoa(v int64) string {
|
||
|
|
if v == 0 {
|
||
|
|
return "0"
|
||
|
|
}
|
||
|
|
neg := v < 0
|
||
|
|
if neg {
|
||
|
|
v = -v
|
||
|
|
}
|
||
|
|
var buf [20]byte
|
||
|
|
i := len(buf)
|
||
|
|
for v > 0 {
|
||
|
|
i--
|
||
|
|
buf[i] = byte('0' + v%10)
|
||
|
|
v /= 10
|
||
|
|
}
|
||
|
|
if neg {
|
||
|
|
i--
|
||
|
|
buf[i] = '-'
|
||
|
|
}
|
||
|
|
return string(buf[i:])
|
||
|
|
}
|