Merge pull request 'fixed verificatoin' (#487) from q-477 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed

Reviewed-on: #487
This commit was merged in pull request #487.
This commit is contained in:
2026-02-12 19:27:04 +00:00
2 changed files with 77 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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()