Scert/backend/internal/httpapi/auth_http_load_test.go

141 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
type blockedLoginRepository struct {
repository.UserRepository
started chan struct{}
release chan struct{}
}
func (r *blockedLoginRepository) FindByIdentifier(ctx context.Context, _ string) (repository.User, error) {
r.started <- struct{}{}
select {
case <-r.release:
return repository.User{}, fmt.Errorf("测试账号不存在")
case <-ctx.Done():
return repository.User{}, ctx.Err()
}
}
func TestHTTPLoginBurstRejectsBeforeAccountLookup(t *testing.T) {
users := &blockedLoginRepository{started: make(chan struct{}, 128), release: make(chan struct{})}
s := New(Dependencies{Config: readyTestConfig(t.TempDir()), Users: users})
server := httptest.NewServer(s.Handler())
defer server.Close()
client := &http.Client{Timeout: 10 * time.Second}
send := func(index int) int {
body := fmt.Sprintf(`{"identifier":"unknown-%d","password":"wrong"}`, index)
r, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/auth/login", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
// 不信任任意转发头;所有套接字仍来自同一个真实来源。
r.Header.Set("X-Forwarded-For", fmt.Sprintf("192.0.2.%d", index%250+1))
response, err := client.Do(r)
if err != nil {
t.Errorf("真实 HTTP 请求失败:%v", err)
return 0
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, response.Body)
return response.StatusCode
}
var pending sync.WaitGroup
for i := 0; i < loginSourceConcurrentLimit; i++ {
pending.Add(1)
go func(i int) { defer pending.Done(); send(i) }(i)
}
defer func() { close(users.release); pending.Wait() }()
for i := 0; i < loginSourceConcurrentLimit; i++ {
select {
case <-users.started:
case <-time.After(5 * time.Second):
t.Fatal("请求没有到达账号查询阶段")
}
}
var burst sync.WaitGroup
for i := 0; i < 128; i++ {
burst.Add(1)
go func(i int) {
defer burst.Done()
if code := send(i + 2); code != http.StatusTooManyRequests {
t.Errorf("来源并发已满时必须在查询前返回 429:%d", code)
}
}(i)
}
burst.Wait()
if len(users.started) != 0 {
t.Fatal("伪造转发头绕过了并发准入")
}
}
func TestSessionCannotReviveAfterSlowAccountLookup(t *testing.T) {
users := &pausedSessionRepository{
started: make(chan struct{}), release: make(chan struct{}),
user: repository.User{ID: "operator", Username: "operator", PasswordHash: "test-hash", Role: repository.UserRoleUser, Status: repository.UserStatusActive},
}
s := New(Dependencies{Config: readyTestConfig(t.TempDir()), Users: users})
now := time.Now()
s.now = func() time.Time { return now }
s.authSessions["slow"] = authSession{
UserID: users.user.ID, Username: users.user.Username, PasswordHash: users.user.PasswordHash, Role: users.user.Role,
ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour),
}
done := make(chan *httptest.ResponseRecorder, 1)
go func() { done <- authSessionRequest(s, &http.Cookie{Name: authCookieName, Value: "slow"}) }()
select {
case <-users.started:
case <-time.After(time.Second):
close(users.release)
t.Fatal("会话没有进入账号查询阶段")
}
now = now.Add(2 * time.Minute)
close(users.release)
if response := <-done; response.Code != http.StatusUnauthorized {
t.Fatal("慢查询不能复活已过期会话")
}
if _, exists := s.authSessions["slow"]; exists {
t.Fatal("过期会话未清理")
}
}
func TestLoginSourceCapacityAndPendingFailureBounds(t *testing.T) {
s := New(Dependencies{Config: readyTestConfig(t.TempDir())})
now := time.Now()
s.now = func() time.Time { return now }
for i := 0; i < authStateCapacity; i++ {
s.loginSources[fmt.Sprint(i)] = loginSource{WindowStart: now, Requests: 1}
}
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
key := loginAttemptKey(r, "admin")
if s.reserveLogin(r, key) || len(s.loginSources) != authStateCapacity {
t.Fatal("来源表饱和时必须有界拒绝")
}
now = now.Add(time.Minute)
if !s.reserveLogin(r, key) {
t.Fatal("来源表过期回收后应恢复")
}
s.releaseLogin(r, key)
for i := 0; i < loginFailureLimit-1; i++ {
s.recordLoginFailure(key)
}
if !s.reserveLogin(r, key) {
t.Fatal("最后一次尝试应允许进入")
}
if s.reserveLogin(r, key) {
t.Fatal("在途密码计算必须计入剩余失败额度")
}
s.releaseLogin(r, key)
}