135 lines
4.4 KiB
Go
135 lines
4.4 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|