143 lines
6.3 KiB
Go
143 lines
6.3 KiB
Go
package account
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store/memory"
|
|
)
|
|
|
|
func TestDeleteAccountTwoFADelayDecisionMatrix(t *testing.T) {
|
|
now := time.Unix(1_800_000_000, 0).UTC()
|
|
authKey := [8]byte{1}
|
|
tests := []struct {
|
|
name string
|
|
hasPassword bool
|
|
passwordUpdated time.Time
|
|
createdAt time.Time
|
|
lastSeen int
|
|
wantKind domain.AccountDeleteKind
|
|
}{
|
|
{name: "no password deletes immediately", createdAt: now.Add(-time.Hour), lastSeen: int(now.Unix()), wantKind: domain.AccountDeleteImmediate},
|
|
{name: "old password and recent activity delays", hasPassword: true, passwordUpdated: now.Add(-8 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteDelayed},
|
|
{name: "recent password change deletes immediately", hasPassword: true, passwordUpdated: now.Add(-2 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
|
|
{name: "inactive account deletes immediately", hasPassword: true, passwordUpdated: now.Add(-30 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-8 * 24 * time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{
|
|
User: domain.User{ID: 42, Phone: "15550010000", CreatedAt: test.createdAt, LastSeenAt: test.lastSeen},
|
|
HasPassword: test.hasPassword, PasswordUpdatedAt: test.passwordUpdated,
|
|
}}
|
|
svc := NewService(memory.NewPasswordStore(), WithAccountLifecycle(lifecycle))
|
|
outcome, err := svc.DeleteAccount(context.Background(), 42, authKey, "manual", nil, now)
|
|
if err != nil {
|
|
t.Fatalf("DeleteAccount: %v", err)
|
|
}
|
|
if outcome.Kind != test.wantKind {
|
|
t.Fatalf("kind = %q, want %q", outcome.Kind, test.wantKind)
|
|
}
|
|
if test.wantKind == domain.AccountDeleteDelayed {
|
|
if lifecycle.scheduled == nil || !strings.Contains(lifecycle.scheduled.ServiceMessage, "tg://confirmphone?") || outcome.WaitSeconds != int(accountDeletionDelay.Seconds()) {
|
|
t.Fatalf("delayed outcome=%+v scheduled=%+v", outcome, lifecycle.scheduled)
|
|
}
|
|
} else if lifecycle.executedSource == "" {
|
|
t.Fatal("immediate path did not execute the tombstone boundary")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfirmPhoneCancelsPendingDeletionAndRevokesRequester(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Unix(1_800_000_000, 0).UTC()
|
|
users := memory.NewUserStore()
|
|
u, err := users.Create(ctx, domain.User{Phone: "15550010001", FirstName: "Alice"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requester := [8]byte{9}
|
|
confirming := [8]byte{8}
|
|
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{User: u}}
|
|
svc := NewService(memory.NewPasswordStore(),
|
|
WithUsers(users),
|
|
WithPhoneChange(nil, nil, memory.NewCodeStore(), nil, "12345", 5*time.Minute, 5),
|
|
WithAccountLifecycle(lifecycle),
|
|
)
|
|
rawToken, digest, err := newAccountDeletionToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lifecycle.pending = &domain.AccountDeletionRequest{
|
|
ID: 1, UserID: u.ID, RequesterAuthKeyID: requester, State: domain.AccountDeletionPending,
|
|
ConfirmHashDigest: digest, RequestedAt: now, ExecuteAt: now.Add(accountDeletionDelay),
|
|
}
|
|
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, u.ID, confirming, 77, rawToken)
|
|
if err != nil || hash == "" || delivery.Length != 5 {
|
|
t.Fatalf("SendConfirmPhoneCode hash=%q delivery=%+v err=%v", hash, delivery, err)
|
|
}
|
|
revoked, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("ConfirmPhone: %v", err)
|
|
}
|
|
if len(revoked) != 1 || revoked[0].AuthKeyID != requester || lifecycle.pending != nil {
|
|
t.Fatalf("revoked=%+v pending=%+v", revoked, lifecycle.pending)
|
|
}
|
|
if _, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(2*time.Minute)); !errors.Is(err, domain.ErrPhoneCodeExpired) {
|
|
t.Fatalf("replay error = %v, want expired", err)
|
|
}
|
|
}
|
|
|
|
type fakeAccountLifecycleStore struct {
|
|
snapshot domain.AccountDeletionSnapshot
|
|
pending *domain.AccountDeletionRequest
|
|
scheduled *domain.ScheduleAccountDeletion
|
|
executedSource domain.AccountDeletionSource
|
|
}
|
|
|
|
func (f *fakeAccountLifecycleStore) AccountDeletionSnapshot(context.Context, int64) (domain.AccountDeletionSnapshot, bool, error) {
|
|
f.snapshot.Pending = f.pending
|
|
return f.snapshot, true, nil
|
|
}
|
|
|
|
func (f *fakeAccountLifecycleStore) ScheduleAccountDeletion(_ context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
|
|
f.scheduled = &req
|
|
pending := domain.AccountDeletionRequest{ID: 1, UserID: req.UserID, RequesterAuthKeyID: req.RequesterAuthKeyID, State: domain.AccountDeletionPending, Reason: req.Reason, ConfirmHashDigest: req.ConfirmHashDigest, RequestedAt: req.RequestedAt, ExecuteAt: req.ExecuteAt}
|
|
f.pending = &pending
|
|
return pending, true, nil
|
|
}
|
|
|
|
func (f *fakeAccountLifecycleStore) PendingAccountDeletionByHash(_ context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
|
|
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
|
|
return domain.AccountDeletionRequest{}, false, nil
|
|
}
|
|
return *f.pending, true, nil
|
|
}
|
|
|
|
func (f *fakeAccountLifecycleStore) ExecuteAccountDeletion(_ context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
|
|
f.executedSource = source
|
|
u := f.snapshot.User
|
|
u.Deleted = true
|
|
u.DeletedAt = now.Unix()
|
|
u.DeletionSource = source
|
|
u.DeletionReason = reason
|
|
u = u.DeletedTombstone()
|
|
return domain.AccountDeletionResult{User: u, Changed: true}, nil
|
|
}
|
|
|
|
func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, userID int64, digest [32]byte, _ time.Time) ([]domain.Authorization, error) {
|
|
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
|
|
return nil, domain.ErrAccountDeletionHashInvalid
|
|
}
|
|
revoked := []domain.Authorization{{AuthKeyID: f.pending.RequesterAuthKeyID, UserID: userID}}
|
|
f.pending = nil
|
|
return revoked, nil
|
|
}
|
|
|
|
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
|
|
return nil, nil
|
|
}
|