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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user