139 lines
5.5 KiB
Go
139 lines
5.5 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
|
)
|
|
|
|
func TestLoginPrunesUnvisitedExpiredState(t *testing.T) {
|
|
cfg := readyTestConfig(t.TempDir())
|
|
cfg.Admin.Username = "admin"
|
|
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
|
server := New(Dependencies{Config: cfg})
|
|
now := time.Now()
|
|
server.now = func() time.Time { return now }
|
|
server.authSessions["expired"] = authSession{ExpiresAt: now.Add(-time.Hour)}
|
|
server.authSessions["absolute-expired"] = authSession{ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(-time.Second)}
|
|
server.authSessions["incomplete"] = authSession{ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(time.Hour)}
|
|
server.loginAttempts["old-key"] = loginAttempt{WindowStart: now.Add(-time.Hour)}
|
|
server.loginAttempts["blocked-key"] = loginAttempt{WindowStart: now.Add(-time.Hour), BlockedTill: now.Add(time.Minute)}
|
|
response := loginAuthTest(t, server, "admin", "AdminPass123", false)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("登录失败:%d", response.Code)
|
|
}
|
|
if len(server.authSessions) != 1 {
|
|
t.Fatal("新登录必须清理不会再被访问的过期会话")
|
|
}
|
|
if _, exists := server.loginAttempts["old-key"]; exists {
|
|
t.Fatal("新登录必须清理过期限流键")
|
|
}
|
|
if _, exists := server.loginAttempts["blocked-key"]; !exists {
|
|
t.Fatal("清理不能缩短仍然有效的封禁期")
|
|
}
|
|
}
|
|
|
|
func TestSessionCapacityDoesNotEvictActiveSessions(t *testing.T) {
|
|
cfg := readyTestConfig(t.TempDir())
|
|
cfg.Admin.Username = "admin"
|
|
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
|
server := New(Dependencies{Config: cfg})
|
|
now := time.Now()
|
|
server.now = func() time.Time { return now }
|
|
for index := 0; index < authStateCapacity; index++ {
|
|
server.authSessions[fmt.Sprint(index)] = authSession{
|
|
UserID: fmt.Sprint(index), Username: "operator", PasswordHash: "test-hash", Role: repository.UserRoleUser,
|
|
ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour),
|
|
}
|
|
}
|
|
response := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
|
|
server.Handler().ServeHTTP(response, request)
|
|
if response.Code != http.StatusServiceUnavailable || len(response.Result().Cookies()) != 0 || len(server.authSessions) != authStateCapacity {
|
|
t.Fatal("会话容量已满时应明确拒绝,不得踢出其他有效会话或设置新 Cookie")
|
|
}
|
|
now = now.Add(2 * time.Minute)
|
|
response = loginAuthTest(t, server, "admin", "AdminPass123", false)
|
|
if response.Code != http.StatusOK || len(server.authSessions) != 1 {
|
|
t.Fatal("过期会话清理后必须恢复登录能力")
|
|
}
|
|
}
|
|
|
|
type pausedSessionRepository struct {
|
|
repository.UserRepository
|
|
started chan struct{}
|
|
release chan struct{}
|
|
user repository.User
|
|
}
|
|
|
|
func (r *pausedSessionRepository) GetUserByID(ctx context.Context, _ string) (repository.User, error) {
|
|
close(r.started)
|
|
select {
|
|
case <-r.release:
|
|
return r.user, nil
|
|
case <-ctx.Done():
|
|
return repository.User{}, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func TestConcurrentLogoutWinsOverPausedSessionRefresh(t *testing.T) {
|
|
users := &pausedSessionRepository{
|
|
started: make(chan struct{}), release: make(chan struct{}),
|
|
user: repository.User{ID: "operator", Username: "operator", Email: "operator@example.test", PasswordHash: "test-hash", Role: repository.UserRoleUser, Status: repository.UserStatusActive},
|
|
}
|
|
server := New(Dependencies{Config: readyTestConfig(t.TempDir()), Users: users})
|
|
now := time.Now()
|
|
server.authSessions["paused"] = authSession{UserID: users.user.ID, Username: users.user.Username, Email: users.user.Email, Role: users.user.Role, PasswordHash: users.user.PasswordHash, ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour)}
|
|
cookie := &http.Cookie{Name: authCookieName, Value: "paused"}
|
|
done := make(chan *httptest.ResponseRecorder, 1)
|
|
go func() { done <- authSessionRequest(server, cookie) }()
|
|
select {
|
|
case <-users.started:
|
|
case <-time.After(time.Second):
|
|
close(users.release)
|
|
t.Fatal("会话未进入账号校验阶段")
|
|
}
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
|
request.AddCookie(cookie)
|
|
server.Handler().ServeHTTP(httptest.NewRecorder(), request)
|
|
close(users.release)
|
|
response := <-done
|
|
if response.Code != http.StatusUnauthorized || len(response.Result().Cookies()) != 0 {
|
|
t.Fatal("并发退出后的旧请求不能继续刷新会话或设置 Cookie")
|
|
}
|
|
}
|
|
|
|
func TestLoginStateHasFiniteCapacity(t *testing.T) {
|
|
server := New(Dependencies{Config: readyTestConfig(t.TempDir())})
|
|
for index := 0; index < 20000; index++ {
|
|
key := fmt.Sprintf("unique-%d", index)
|
|
if server.allowLogin(key) {
|
|
server.recordLoginFailure(key)
|
|
}
|
|
}
|
|
if len(server.loginAttempts) > 4096 {
|
|
t.Fatalf("失败登录记录无界增长:%d", len(server.loginAttempts))
|
|
}
|
|
if server.allowLogin("new-key-after-capacity") {
|
|
t.Fatal("限流表已满时不能继续接受未知登录键")
|
|
}
|
|
}
|
|
|
|
func TestLoginAttemptKeyDoesNotRetainLargeIdentifier(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
|
request.RemoteAddr = "127.0.0.1:12345"
|
|
identifier := fmt.Sprintf("%016000d", 1)
|
|
if len(loginAttemptKey(request, identifier)) > 128 {
|
|
t.Fatal("限流键不应保留大体积用户输入")
|
|
}
|
|
if loginAttemptKey(request, " ADMIN ") != loginAttemptKey(request, "admin") {
|
|
t.Fatal("账号规范化规则不能因散列键改变")
|
|
}
|
|
}
|