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, Role: domainUser.RoleOwner} sessions := newMemorySessionRepo() container := &api.UseCases{ Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))), GetUserUseCase: useCases.NewGetUserUseCase(userRepo), GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()), GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, 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") } }) }