feat(backend): implement HTTP API, auth, chat, and admin surfaces

Expose learning, registration controls, mentor chat, realtime WebSocket, and staff administration.
This commit is contained in:
ahp
2026-10-07 19:40:26 +03:30
parent 3ee609337c
commit 422e6ed289
58 changed files with 11756 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
package api_test
import (
"context"
"encoding/base64"
"net/http"
"testing"
"time"
)
func TestCaptchaRequiredOnLogin(t *testing.T) {
e := setup(t)
code, body := e.do(t, "POST", "/api/auth/login", "", map[string]any{
"email": bootstrapAdminEmail, "password": "password123",
})
if code != http.StatusBadRequest || body["error"] != "کد امنیتی الزامی است" {
t.Fatalf("expected captcha required, got %d %v", code, body)
}
}
func TestCaptchaImageChallenge(t *testing.T) {
e := setup(t)
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)
}
if _, ok := body["question"]; ok {
t.Fatalf("legacy question field should be gone: %v", body)
}
raw, err := base64.StdEncoding.DecodeString(img)
if err != nil || len(raw) < 32 {
t.Fatalf("imageBase64 not valid PNG payload: err=%v len=%d", err, len(raw))
}
if raw[0] != 0x89 || string(raw[1:4]) != "PNG" {
t.Fatalf("imageBase64 is not a PNG")
}
if len(answer) < 4 || len(answer) > 5 {
t.Fatalf("unexpected answer length: %q", answer)
}
}
func TestCaptchaWrongRejected(t *testing.T) {
e := setup(t)
id, answer := fetchCaptcha(t, e)
code, body := e.do(t, "POST", "/api/auth/login", "", map[string]any{
"email": bootstrapAdminEmail, "password": "password123",
"challengeId": id, "captchaAnswer": "ZZZZZ",
})
if code != http.StatusBadRequest || body["error"] != "پاسخ کد امنیتی نادرست یا منقضی است" {
t.Fatalf("expected wrong captcha, got %d %v", code, body)
}
// Challenge is one-time: reuse must fail even with the correct answer later.
code, body = e.do(t, "POST", "/api/auth/login", "", map[string]any{
"email": bootstrapAdminEmail, "password": "password123",
"challengeId": id, "captchaAnswer": answer,
})
if code != http.StatusBadRequest {
t.Fatalf("reused challenge should fail, got %d %v", code, body)
}
}
func TestRememberMeRefreshTTL(t *testing.T) {
e := setup(t)
ctx := context.Background()
assertTTL := func(remember bool, wantMin, wantMax time.Duration) {
t.Helper()
body := loginBody(t, e, bootstrapAdminEmail, "password123")
body["rememberMe"] = remember
code, resp := e.do(t, "POST", "/api/auth/login", "", body)
if code != http.StatusOK {
t.Fatalf("login rememberMe=%v: %d %v", remember, code, resp)
}
var lifetime time.Duration
err := e.pool.QueryRow(ctx, `
SELECT expires_at - created_at
FROM refresh_tokens
WHERE revoked_at IS NULL
ORDER BY id DESC
LIMIT 1`).Scan(&lifetime)
if err != nil {
t.Fatalf("query refresh ttl: %v", err)
}
if lifetime < wantMin || lifetime > wantMax {
t.Fatalf("rememberMe=%v: lifetime %v not in [%v, %v]", remember, lifetime, wantMin, wantMax)
}
}
assertTTL(false, 23*time.Hour, 25*time.Hour)
assertTTL(true, 29*24*time.Hour, 31*24*time.Hour)
}
func TestCaptchaCorrectLogin(t *testing.T) {
e := setup(t)
code, body := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, bootstrapAdminEmail, "password123"))
if code != http.StatusOK {
t.Fatalf("login with captcha: %d %v", code, body)
}
if body["accessToken"] == nil {
t.Fatalf("missing access token: %v", body)
}
}
func TestSuccessfulLoginClearsPenalty(t *testing.T) {
e := setup(t)
// Accumulate a few failures (wrong password with valid captcha).
for i := 0; i < 4; i++ {
code, _ := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "missing@test.dev", "wrong"))
if code != http.StatusUnauthorized {
t.Fatalf("fail %d: %d", i+1, code)
}
}
// Successful login should clear the fail counter for this IP.
code, body := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, bootstrapAdminEmail, "password123"))
if code != http.StatusOK {
t.Fatalf("success login: %d %v", code, body)
}
// After clear, several more wrong attempts should still be allowed (not immediately locked to 2/min).
for i := 0; i < 5; i++ {
code, body := e.do(t, "POST", "/api/auth/login", "", loginBody(t, e, "missing@test.dev", "wrong"))
if code == http.StatusTooManyRequests {
t.Fatalf("should not be rate-limited so soon after successful login clear, attempt %d: %v", i+1, body)
}
if code != http.StatusUnauthorized {
t.Fatalf("attempt %d: %d %v", i+1, code, body)
}
}
}