Files
RenewCMS/test/personal_data_retention_test.go
T

241 lines
8.3 KiB
Go

package test
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/retention"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/scheduler"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"log"
"strings"
"testing"
"time"
)
var retentionNow = time.Date(2026, time.October, 22, 3, 0, 0, 0, time.UTC)
func TestUnverifiedAccountRetentionRule(t *testing.T) {
cases := []struct {
name string
user user.User
stale bool
}{
{"verified accounts are never stale", user.User{IsVerified: true, VerificationExpiration: retentionNow.Add(-365 * 24 * time.Hour)}, false},
{"a pending code is not stale", user.User{VerificationExpiration: retentionNow.Add(time.Hour)}, false},
{"an expired code inside the grace period is not stale", user.User{VerificationExpiration: retentionNow.Add(-user.UnverifiedRetention)}, false},
{"an expired code past the grace period is stale", user.User{VerificationExpiration: retentionNow.Add(-user.UnverifiedRetention - time.Second)}, true},
{"without an expiry, the creation date starts the grace period", user.User{CreatedAt: retentionNow.Add(-user.UnverifiedRetention - time.Second)}, true},
{"without any date, an account is kept", user.User{}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.user.IsStaleUnverifiedAt(retentionNow); got != tc.stale {
t.Errorf("expected %v, got %v", tc.stale, got)
}
})
}
}
func TestInviteRetentionRule(t *testing.T) {
acceptedLongAgo := retentionNow.Add(-invite.Retention - time.Second)
acceptedRecently := retentionNow.Add(-invite.Retention)
cases := []struct {
name string
invite invite.Invite
stale bool
}{
{"a pending invite is kept", invite.Invite{ExpiresAt: retentionNow.Add(time.Hour)}, false},
{"an invite that expired inside the retention period is kept", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention)}, false},
{"an invite that expired before the retention period is stale", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention - time.Second)}, true},
{"an invite accepted inside the retention period is kept", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention * 2), AcceptedAt: &acceptedRecently}, false},
{"an invite accepted before the retention period is stale", invite.Invite{ExpiresAt: retentionNow.Add(time.Hour), AcceptedAt: &acceptedLongAgo}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.invite.IsStaleAt(retentionNow); got != tc.stale {
t.Errorf("expected %v, got %v", tc.stale, got)
}
})
}
}
type retentionWorld struct {
users *mockUserRepo
invites *inviteFakeRepo
sessions *memorySessionRepo
useCase *useCases.EnforceRetentionUseCase
}
func newRetentionWorld() *retentionWorld {
w := &retentionWorld{users: newMockUserRepo(), invites: newInviteFakeRepo(), sessions: newMemorySessionRepo()}
w.useCase = useCases.NewEnforceRetentionUseCase(w.users, w.invites, w.sessions)
return w
}
func (w *retentionWorld) seedEverything() {
longAgo := retentionNow.Add(-60 * 24 * time.Hour)
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, VerificationExpiration: longAgo}
w.users.users[2] = user.User{ID: 2, Username: "abandoned", VerificationExpiration: longAgo}
w.users.users[3] = user.User{ID: 3, Username: "pending", VerificationExpiration: retentionNow.Add(time.Hour)}
w.invites.seed(invite.Invite{Email: "old-expired@example.com", ExpiresAt: longAgo})
w.invites.seed(invite.Invite{Email: "pending@example.com", ExpiresAt: retentionNow.Add(time.Hour)})
w.invites.seed(invite.Invite{Email: "old-accepted@example.com", ExpiresAt: longAgo, AcceptedAt: &longAgo})
w.sessions.revoked["expired"] = retentionNow.Add(-time.Minute)
w.sessions.revoked["live"] = retentionNow.Add(time.Hour)
}
func TestEnforceRetentionUseCase(t *testing.T) {
t.Run("Removes only what is past its retention period and reports the counts", func(t *testing.T) {
w := newRetentionWorld()
w.seedEverything()
result, err := w.useCase.Tick(retentionNow)
if err != nil {
t.Fatal(err)
}
if result != (retention.Result{RevokedSessions: 1, UnverifiedUsers: 1, Invites: 2}) {
t.Errorf("unexpected result %+v", result)
}
if _, kept := w.users.users[1]; !kept {
t.Error("the verified owner must be kept")
}
if _, kept := w.users.users[3]; !kept {
t.Error("the pending account must be kept")
}
if _, removed := w.users.users[2]; removed {
t.Error("the abandoned account must be removed")
}
remaining := allOf(w.invites.GetAll())
if len(remaining) != 1 || remaining[0].Email != "pending@example.com" {
t.Errorf("expected only the pending invite kept, got %+v", remaining)
}
if _, kept := w.sessions.revoked["live"]; !kept || len(w.sessions.revoked) != 1 {
t.Errorf("expected only the live revocation kept, got %v", w.sessions.revoked)
}
})
t.Run("A second run has nothing left to remove", func(t *testing.T) {
w := newRetentionWorld()
w.seedEverything()
_, _ = w.useCase.Tick(retentionNow)
result, err := w.useCase.Tick(retentionNow)
if err != nil || result.Total() != 0 {
t.Errorf("expected nothing removed, got %+v, %v", result, err)
}
})
t.Run("A session store failure stops the run before anything else is touched", func(t *testing.T) {
w := newRetentionWorld()
w.seedEverything()
w.sessions.failure = errors.New("database unavailable")
_, err := w.useCase.Tick(retentionNow)
if err == nil {
t.Fatal("expected an error")
}
if len(w.users.users) != 3 || len(w.invites.invites) != 3 {
t.Error("users and invites must be untouched")
}
})
t.Run("A user deletion failure is reported with the work done so far", func(t *testing.T) {
w := newRetentionWorld()
w.seedEverything()
w.users.fail.failNext("Delete")
result, err := w.useCase.Tick(retentionNow)
if err == nil {
t.Fatal("expected an error")
}
if result.RevokedSessions != 1 || result.UnverifiedUsers != 0 || result.Invites != 0 {
t.Errorf("unexpected partial result %+v", result)
}
if len(w.invites.invites) != 3 {
t.Error("invites must be untouched after the failure")
}
})
t.Run("An invite deletion failure is reported with the work done so far", func(t *testing.T) {
w := newRetentionWorld()
w.seedEverything()
w.invites.fail.failNext("Delete")
result, err := w.useCase.Tick(retentionNow)
if err == nil {
t.Fatal("expected an error")
}
if result.RevokedSessions != 1 || result.UnverifiedUsers != 1 || result.Invites != 0 {
t.Errorf("unexpected partial result %+v", result)
}
})
}
type retentionJobFake struct {
calls chan time.Time
result retention.Result
err error
}
func (f *retentionJobFake) Tick(now time.Time) (retention.Result, error) {
f.calls <- now
return f.result, f.err
}
func TestRetentionScheduler(t *testing.T) {
run := func(t *testing.T, job *retentionJobFake) string {
t.Helper()
ticks := make(chan time.Time, 1)
logs := &strings.Builder{}
runner := scheduler.NewRetentionRunner(job, func() time.Time { return retentionNow }, ticks, nil, log.New(logs, "", 0))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
runner.Run(ctx)
}()
ticks <- time.Time{}
select {
case got := <-job.calls:
if !got.Equal(retentionNow) {
t.Errorf("expected the injected clock, got %v", got)
}
case <-time.After(2 * time.Second):
t.Fatal("the job was never ticked")
}
cancel()
<-done
return logs.String()
}
t.Run("A run that removed something is logged with its counts", func(t *testing.T) {
logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1), result: retention.Result{RevokedSessions: 2, UnverifiedUsers: 1, Invites: 3}})
if !strings.Contains(logged, "personal data retention completed: revoked_sessions=2 unverified_users=1 invites=3") {
t.Errorf("unexpected log %q", logged)
}
})
t.Run("A run that removed nothing stays quiet", func(t *testing.T) {
if logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1)}); logged != "" {
t.Errorf("expected no log, got %q", logged)
}
})
t.Run("A failed run is logged", func(t *testing.T) {
logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1), err: errors.New("database unavailable")})
if !strings.Contains(logged, "personal data retention failed: database unavailable") {
t.Errorf("unexpected log %q", logged)
}
})
}