feat(account): retention TTL reaper (2-year legal hold)
Daily background reaper purges the deletion dossier past its two-year TTL: every retained_identities row by detached_at (covering unlink/change on live accounts too), and — for accounts tombstoned before the cutoff — the retained feedback thread plus the dossier PII (deleted_display_name, last_login_ip). Chat is kept (a shared game artifact) and the tombstone row stays. Started from main next to the guest reaper. Integration test covers the cutoff boundary and the deleted-account feedback/PII purge.
This commit is contained in:
@@ -6,10 +6,12 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/accountdelete"
|
||||
)
|
||||
|
||||
// lastLoginIP reads an account's stamped last-login IP ("" when unset).
|
||||
@@ -126,3 +128,65 @@ func TestStampLastLoginThrottles(t *testing.T) {
|
||||
t.Fatalf("throttled ip = %q, want unchanged 1.2.3.4", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReapExpiredRetention: the reaper keeps journal rows newer than the cutoff and purges
|
||||
// older ones, and drops a long-deleted account's feedback thread + dossier PII.
|
||||
func TestReapExpiredRetention(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
deleter := accountdelete.NewDeleter(testDB)
|
||||
|
||||
// An unlinked provider leaves a journal row detached "now".
|
||||
tgExt := "tg-" + uuid.NewString()
|
||||
acc, err := store.ProvisionByIdentity(ctx, account.KindTelegram, tgExt)
|
||||
if err != nil {
|
||||
t.Fatalf("provision: %v", err)
|
||||
}
|
||||
if err := store.AttachIdentity(ctx, acc.ID, account.KindEmail, "keep-"+uuid.NewString()+"@example.com", true); err != nil {
|
||||
t.Fatalf("attach email: %v", err)
|
||||
}
|
||||
if err := store.RemoveIdentity(ctx, acc.ID, account.KindTelegram); err != nil {
|
||||
t.Fatalf("unlink: %v", err)
|
||||
}
|
||||
// A cutoff before the detach keeps the row.
|
||||
if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(-time.Hour)); err != nil {
|
||||
t.Fatalf("reap (early cutoff): %v", err)
|
||||
}
|
||||
if got := retainedRows(t, acc.ID); len(got) != 1 {
|
||||
t.Fatalf("journal after early-cutoff reap = %+v, want kept", got)
|
||||
}
|
||||
// A cutoff after the detach purges it.
|
||||
if _, _, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("reap (late cutoff): %v", err)
|
||||
}
|
||||
if got := retainedRows(t, acc.ID); len(got) != 0 {
|
||||
t.Fatalf("journal after late-cutoff reap = %+v, want purged", got)
|
||||
}
|
||||
|
||||
// A deleted account past the cutoff loses its feedback thread and dossier PII.
|
||||
del, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Иван", "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision deletee: %v", err)
|
||||
}
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
"INSERT INTO feedback_messages (message_id, account_id, body, channel) VALUES ($1, $2, 'hi', 'web')",
|
||||
uuid.New(), del.ID); err != nil {
|
||||
t.Fatalf("insert feedback: %v", err)
|
||||
}
|
||||
if err := deleter.AnonymizeAndTombstone(ctx, del.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, fb, err := store.ReapExpiredRetention(ctx, time.Now().Add(time.Hour)); err != nil || fb == 0 {
|
||||
t.Fatalf("reap deleted = (fb %d, err %v), want fb>=1", fb, err)
|
||||
}
|
||||
if name, _ := deletedFields(t, del.ID); name != "" {
|
||||
t.Errorf("deleted_display_name after reap = %q, want cleared", name)
|
||||
}
|
||||
var fbCount int
|
||||
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM feedback_messages WHERE account_id = $1", del.ID).Scan(&fbCount); err != nil {
|
||||
t.Fatalf("count feedback: %v", err)
|
||||
}
|
||||
if fbCount != 0 {
|
||||
t.Errorf("feedback rows after reap = %d, want 0", fbCount)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user