311 lines
10 KiB
Go
311 lines
10 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api"
|
|
"RenewCMS/api/pkg/authtoken"
|
|
validator "RenewCMS/api/pkg/validator"
|
|
"RenewCMS/internal/domain/session"
|
|
domainUser "RenewCMS/internal/domain/user"
|
|
"RenewCMS/internal/infrastructure/config"
|
|
"RenewCMS/internal/infrastructure/hashing"
|
|
"RenewCMS/internal/infrastructure/persistence/database"
|
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"bytes"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type memorySessionRepo struct {
|
|
revoked map[string]time.Time
|
|
failure error
|
|
revokeFailure error
|
|
}
|
|
|
|
func newMemorySessionRepo() *memorySessionRepo {
|
|
return &memorySessionRepo{revoked: map[string]time.Time{}}
|
|
}
|
|
|
|
func (m *memorySessionRepo) Revoke(revocation session.Revocation) error {
|
|
if m.failure != nil {
|
|
return m.failure
|
|
}
|
|
if m.revokeFailure != nil {
|
|
return m.revokeFailure
|
|
}
|
|
m.revoked[revocation.ID] = revocation.ExpiresAt
|
|
return nil
|
|
}
|
|
|
|
func (m *memorySessionRepo) IsRevoked(id string) (bool, error) {
|
|
if m.failure != nil {
|
|
return false, m.failure
|
|
}
|
|
_, ok := m.revoked[id]
|
|
return ok, nil
|
|
}
|
|
|
|
func (m *memorySessionRepo) DeleteExpired(now time.Time) (int, error) {
|
|
if m.failure != nil {
|
|
return 0, m.failure
|
|
}
|
|
removed := 0
|
|
for id, expiresAt := range m.revoked {
|
|
if !expiresAt.After(now) {
|
|
delete(m.revoked, id)
|
|
removed++
|
|
}
|
|
}
|
|
return removed, nil
|
|
}
|
|
|
|
var _ session.Repository = &memorySessionRepo{}
|
|
|
|
func TestSessionRevocationUseCase(t *testing.T) {
|
|
now := time.Date(2026, time.October, 22, 9, 0, 0, 0, time.UTC)
|
|
clock := func() time.Time { return now }
|
|
|
|
t.Run("A revoked session is reported as revoked until it is purged", func(t *testing.T) {
|
|
repo := newMemorySessionRepo()
|
|
uc := useCases.NewSessionRevocationUseCase(repo, clock)
|
|
|
|
if err := uc.Revoke("session-1", now.Add(time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
revoked, err := uc.IsRevoked("session-1")
|
|
if err != nil || !revoked {
|
|
t.Errorf("expected revoked, got %v, %v", revoked, err)
|
|
}
|
|
if other, _ := uc.IsRevoked("session-2"); other {
|
|
t.Error("an unrelated session must not be revoked")
|
|
}
|
|
})
|
|
|
|
t.Run("A session that has already expired is not stored", func(t *testing.T) {
|
|
repo := newMemorySessionRepo()
|
|
|
|
if err := useCases.NewSessionRevocationUseCase(repo, clock).Revoke("session-1", now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(repo.revoked) != 0 {
|
|
t.Errorf("expected nothing stored, got %v", repo.revoked)
|
|
}
|
|
})
|
|
|
|
for name, id := range map[string]string{"empty": "", "too long": strings.Repeat("a", session.MaxIDLength+1)} {
|
|
t.Run("Rejects an "+name+" session id", func(t *testing.T) {
|
|
uc := useCases.NewSessionRevocationUseCase(newMemorySessionRepo(), clock)
|
|
|
|
if err := uc.Revoke(id, now.Add(time.Hour)); !errors.Is(err, session.ErrInvalidSessionID) {
|
|
t.Errorf("Revoke: expected ErrInvalidSessionID, got %v", err)
|
|
}
|
|
if _, err := uc.IsRevoked(id); !errors.Is(err, session.ErrInvalidSessionID) {
|
|
t.Errorf("IsRevoked: expected ErrInvalidSessionID, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("Accepts an id of exactly the maximum length", func(t *testing.T) {
|
|
repo := newMemorySessionRepo()
|
|
id := strings.Repeat("a", session.MaxIDLength)
|
|
|
|
if err := useCases.NewSessionRevocationUseCase(repo, clock).Revoke(id, now.Add(time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := repo.revoked[id]; !ok {
|
|
t.Error("expected the revocation to be stored")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRevokedSessionRepository(t *testing.T) {
|
|
now := time.Date(2026, time.October, 22, 9, 0, 0, 0, time.UTC)
|
|
|
|
t.Run("Revocations survive, revoking twice is harmless, and expired ones are purged", func(t *testing.T) {
|
|
db := openRealSqlite(t)
|
|
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
repo := persistence.NewRevokedSessionRepository(db)
|
|
|
|
for _, revocation := range []session.Revocation{
|
|
{ID: "expired", ExpiresAt: now.Add(-time.Minute)},
|
|
{ID: "exactly-now", ExpiresAt: now},
|
|
{ID: "live", ExpiresAt: now.Add(time.Hour)},
|
|
{ID: "live", ExpiresAt: now.Add(time.Hour)},
|
|
} {
|
|
if err := repo.Revoke(revocation); err != nil {
|
|
t.Fatalf("revoke %s: %v", revocation.ID, err)
|
|
}
|
|
}
|
|
|
|
removed, err := repo.DeleteExpired(now)
|
|
if err != nil || removed != 2 {
|
|
t.Fatalf("expected 2 purged, got %d, %v", removed, err)
|
|
}
|
|
for id, want := range map[string]bool{"expired": false, "exactly-now": false, "live": true, "unknown": false} {
|
|
if got, err := repo.IsRevoked(id); err != nil || got != want {
|
|
t.Errorf("%s: expected %v, got %v, %v", id, want, got, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
type sessionRevocationEnv struct {
|
|
router http.Handler
|
|
sessions *memorySessionRepo
|
|
}
|
|
|
|
func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv {
|
|
t.Helper()
|
|
validator.InitValidator()
|
|
hasher := hashing.NewBcryptHasher()
|
|
hashed, err := hasher.Hash("hunter2example")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
userRepo := newMockUserRepo()
|
|
userRepo.users[1] = domainUser.User{ID: 1, Username: "alice", Email: "alice@example.com", Password: hashed, IsVerified: true, RoleID: ownerRoleID}
|
|
sessions := newMemorySessionRepo()
|
|
container := &api.UseCases{
|
|
Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))),
|
|
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
|
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
|
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
|
|
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()),
|
|
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, newSeededMockRoleRepo(), hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
|
|
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
|
}
|
|
settings := config.Settings{
|
|
Environment: "production",
|
|
PublicURL: "https://cms.example.com",
|
|
AuthRateLimit: config.AuthRateLimitSettings{MaxAttempts: 100, Window: time.Minute},
|
|
}
|
|
return &sessionRevocationEnv{router: api.InitRoutes(container, settings), sessions: sessions}
|
|
}
|
|
|
|
func (e *sessionRevocationEnv) send(method string, target string, body string, cookie *http.Cookie, headers map[string]string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
for name, value := range headers {
|
|
req.Header.Set(name, value)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func (e *sessionRevocationEnv) login(t *testing.T) *http.Cookie {
|
|
t.Helper()
|
|
rec := e.send(http.MethodPost, "/v1/auth/login", `{"username":"alice","password":"hunter2example"}`, nil, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("login: expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
cookie, ok := jwtSetCookie(t, rec)
|
|
if !ok {
|
|
t.Fatal("login: expected a jwt cookie")
|
|
}
|
|
return cookie
|
|
}
|
|
|
|
func TestLogoutRevokesTheSession(t *testing.T) {
|
|
sameOrigin := map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": "https://cms.example.com"}
|
|
|
|
t.Run("The session cookie is HttpOnly, Secure and SameSite=Lax", func(t *testing.T) {
|
|
cookie := newSessionRevocationEnv(t).login(t)
|
|
|
|
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode {
|
|
t.Errorf("expected HttpOnly, Secure and SameSite=Lax, got %+v", cookie)
|
|
}
|
|
})
|
|
|
|
t.Run("After logout the same token is refused while other sessions keep working", func(t *testing.T) {
|
|
env := newSessionRevocationEnv(t)
|
|
loggedOut := env.login(t)
|
|
otherDevice := env.login(t)
|
|
|
|
if rec := env.send(http.MethodGet, "/v1/auth/me", "", loggedOut, nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("before logout: expected 200, got %d", rec.Code)
|
|
}
|
|
|
|
rec := env.send(http.MethodPost, "/v1/auth/logout", "", loggedOut, sameOrigin)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("logout: expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
cleared, ok := jwtSetCookie(t, rec)
|
|
if !ok || cleared.Value != "" || cleared.MaxAge >= 0 || cleared.SameSite != http.SameSiteLaxMode {
|
|
t.Errorf("expected a cleared SameSite=Lax cookie, got %+v", cleared)
|
|
}
|
|
|
|
if rec := env.send(http.MethodGet, "/v1/auth/me", "", loggedOut, nil); rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("replayed token: expected 401, got %d", rec.Code)
|
|
}
|
|
if rec := env.send(http.MethodGet, "/v1/auth/me", "", otherDevice, nil); rec.Code != http.StatusOK {
|
|
t.Errorf("other session: expected 200, got %d", rec.Code)
|
|
}
|
|
if len(env.sessions.revoked) != 1 {
|
|
t.Errorf("expected one revocation stored, got %v", env.sessions.revoked)
|
|
}
|
|
})
|
|
|
|
t.Run("Logging out without a session still clears the cookie and stores nothing", func(t *testing.T) {
|
|
env := newSessionRevocationEnv(t)
|
|
|
|
rec := env.send(http.MethodPost, "/v1/auth/logout", "", nil, sameOrigin)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
if len(env.sessions.revoked) != 0 {
|
|
t.Errorf("expected nothing stored, got %v", env.sessions.revoked)
|
|
}
|
|
})
|
|
|
|
t.Run("A failed revocation answers 500 but still clears the cookie", func(t *testing.T) {
|
|
env := newSessionRevocationEnv(t)
|
|
cookie := env.login(t)
|
|
env.sessions.revokeFailure = errors.New("database unavailable")
|
|
|
|
rec := env.send(http.MethodPost, "/v1/auth/logout", "", cookie, sameOrigin)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500, got %d", rec.Code)
|
|
}
|
|
if cleared, ok := jwtSetCookie(t, rec); !ok || cleared.Value != "" {
|
|
t.Errorf("expected the cookie to be cleared, got %+v", cleared)
|
|
}
|
|
})
|
|
|
|
t.Run("A token whose revocation cannot be checked is refused", func(t *testing.T) {
|
|
env := newSessionRevocationEnv(t)
|
|
cookie := env.login(t)
|
|
env.sessions.failure = errors.New("database unavailable")
|
|
|
|
if rec := env.send(http.MethodGet, "/v1/auth/me", "", cookie, nil); rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("A cross-site logout POST is refused before it reaches the handler", func(t *testing.T) {
|
|
env := newSessionRevocationEnv(t)
|
|
cookie := env.login(t)
|
|
|
|
rec := env.send(http.MethodPost, "/v1/auth/logout", "", cookie, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"})
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("expected 403, got %d", rec.Code)
|
|
}
|
|
if len(env.sessions.revoked) != 0 {
|
|
t.Error("the session must not be touched")
|
|
}
|
|
})
|
|
}
|