diff --git a/api/pkg/db/internal/mongo/verificationimp/create.go b/api/pkg/db/internal/mongo/verificationimp/create.go index c4327cbc..f627cf26 100644 --- a/api/pkg/db/internal/mongo/verificationimp/create.go +++ b/api/pkg/db/internal/mongo/verificationimp/create.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/pkg/db/verification" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" ) func normalizedIdempotencyKey(value *string) (string, bool) { @@ -24,6 +25,10 @@ func normalizedIdempotencyKey(value *string) (string, bool) { return key, true } +func syntheticIdempotencyKey() string { + return "auto:" + bson.NewObjectID().Hex() +} + func idempotencyFilter( request *verification.Request, idempotencyKey string, @@ -119,6 +124,13 @@ func (db *verificationDB) Create( } idempotencyKey, hasIdempotency := normalizedIdempotencyKey(request.IdempotencyKey) + if !hasIdempotency { + // Legacy deployments may still enforce uniqueness on (accountRef, purpose, target, idempotencyKey), + // where missing idempotency key behaves like a shared null key. Assign an internal per-request key + // so token reissue works even when callers do not provide idempotency explicitly. + idempotencyKey = syntheticIdempotencyKey() + hasIdempotency = true + } token, raw, err := newVerificationToken(request, idempotencyKey, hasIdempotency) if err != nil { diff --git a/api/pkg/db/internal/mongo/verificationimp/verification_test.go b/api/pkg/db/internal/mongo/verificationimp/verification_test.go index e1312a88..364c87c9 100644 --- a/api/pkg/db/internal/mongo/verificationimp/verification_test.go +++ b/api/pkg/db/internal/mongo/verificationimp/verification_test.go @@ -3,6 +3,7 @@ package verificationimp import ( "context" "errors" + "strings" "sync" "testing" "time" @@ -101,6 +102,27 @@ func (m *memoryTokenRepository) Insert(_ context.Context, obj storable.Storable, if _, exists := m.data[*id]; exists { return merrors.DataConflict("token already exists") } + for _, existing := range m.data { + if existing.VerifyTokenHash == tok.VerifyTokenHash { + return merrors.DataConflict("duplicate verifyTokenHash") + } + if existing.AccountRef != tok.AccountRef { + continue + } + if existing.Purpose != tok.Purpose { + continue + } + if existing.Target != tok.Target { + continue + } + + switch { + case existing.IdempotencyKey == nil && tok.IdempotencyKey == nil: + return merrors.DataConflict("duplicate verification context idempotency") + case existing.IdempotencyKey != nil && tok.IdempotencyKey != nil && *existing.IdempotencyKey == *tok.IdempotencyKey: + return merrors.DataConflict("duplicate verification context idempotency") + } + } m.data[*id] = cloneToken(tok) m.order = append(m.order, *id) return nil @@ -633,6 +655,49 @@ func TestCreate_InvalidatesPreviousToken(t *testing.T) { assert.Equal(t, accountRef, tok.AccountRef) } +func TestCreate_AccountActivationResendWithoutIdempotency_ReissuesToken(t *testing.T) { + db := newTestVerificationDB(t) + ctx := context.Background() + accountRef := bson.NewObjectID() + + // First issue during signup. + firstRaw, err := db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) + require.NoError(t, err) + + // Second issue during resend should rotate token instead of failing with duplicate key. + secondRaw, err := db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) + require.NoError(t, err) + assert.NotEqual(t, firstRaw, secondRaw) + + // Old token becomes unusable after reissue. + _, err = db.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, firstRaw) + require.Error(t, err) + assert.True(t, errors.Is(err, verification.ErrTokenAlreadyUsed)) + + // New token is valid. + tok, err := db.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, secondRaw) + require.NoError(t, err) + assert.Equal(t, accountRef, tok.AccountRef) + assert.Equal(t, model.PurposeAccountActivation, tok.Purpose) + + // Non-idempotent requests should still persist unique internal keys, + // preventing uniqueness collisions on (accountRef, purpose, target, idempotencyKey). + repo := db.Repository.(*memoryTokenRepository) + repo.mu.Lock() + defer repo.mu.Unlock() + + keys := map[string]struct{}{} + for _, stored := range repo.data { + if stored.AccountRef != accountRef || stored.Purpose != model.PurposeAccountActivation { + continue + } + require.NotNil(t, stored.IdempotencyKey) + assert.True(t, strings.HasPrefix(*stored.IdempotencyKey, "auto:")) + keys[*stored.IdempotencyKey] = struct{}{} + } + assert.Len(t, keys, 2) +} + func TestCreate_InvalidatesMultiplePreviousTokens(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background()