Files
RenewCMS/test/session_revocation_http_test.go
T

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")
}
})
}