package verification import ( "context" "errors" "testing" "time" "git.sendico.io/tech/sxchnge/bff/pkg/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" ) func TestCreate_CooldownBlocksCreation(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) r2 := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithCooldown(time.Minute) _, err = db.Create(ctx, r2) require.Error(t, err) assert.True(t, errors.Is(err, ErrCooldownActive)) } func TestCreate_CooldownExpiresAllowsCreation(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() firstRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) time.Sleep(2 * time.Millisecond) r2 := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithCooldown(time.Millisecond) secondRaw, err := db.Create(ctx, r2) require.NoError(t, err) assert.NotEqual(t, firstRaw, secondRaw) _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", firstRaw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed)) tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", secondRaw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } func TestCreate_CooldownNilIgnored(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) _, err = db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) } func TestCreate_IdempotencyKeyReplayReturnsSameToken(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() firstReq := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithIdempotencyKey("same-key") firstRaw, err := db.Create(ctx, firstReq) require.NoError(t, err) require.NotEmpty(t, firstRaw) secondReq := req(accountRef, model.PurposePasswordReset, "", time.Hour).WithIdempotencyKey("same-key") secondRaw, err := db.Create(ctx, secondReq) require.NoError(t, err) assert.Equal(t, firstRaw, secondRaw) repo := db.Repository.(*memoryTokenRepository) repo.mu.Lock() assert.Len(t, repo.data, 1) repo.mu.Unlock() } func TestCreateWithResult_IdempotencyReplayReportsNotCreated(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() firstReq := req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") first, err := db.CreateWithResult(ctx, firstReq) require.NoError(t, err) require.NotEmpty(t, first.VerificationToken) assert.True(t, first.Created) secondReq := req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") second, err := db.CreateWithResult(ctx, secondReq) require.NoError(t, err) assert.Equal(t, first.VerificationToken, second.VerificationToken) assert.False(t, second.Created) repo := db.Repository.(*memoryTokenRepository) repo.mu.Lock() assert.Len(t, repo.data, 1) repo.mu.Unlock() } func TestCreateWithResult_IdempotencyReplayReusesExpiredStoredExpiry(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() firstReq := req(accountRef, model.PurposeEmailChange, "new@example.com", -time.Minute).WithIdempotencyKey("same-key") first, err := db.CreateWithResult(ctx, firstReq) require.NoError(t, err) require.NotEmpty(t, first.VerificationToken) require.True(t, first.ExpiresAt.Before(time.Now().UTC()), "test must create an already expired token") secondReq := req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour).WithIdempotencyKey("same-key") second, err := db.CreateWithResult(ctx, secondReq) require.NoError(t, err) assert.Equal(t, first.VerificationToken, second.VerificationToken) assert.False(t, second.Created) assert.True(t, second.ExpiresAt.Equal(first.ExpiresAt), "replay must reuse stored expiry") assert.True(t, second.ExpiresAt.Before(time.Now().UTC()), "replay must not extend token validity") } func TestCreate_IdempotencyScopeIncludesTarget(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() r1 := req(accountRef, model.PurposeEmailChange, "a@example.com", time.Hour).WithIdempotencyKey("same-key") raw1, err := db.Create(ctx, r1) require.NoError(t, err) require.NotEmpty(t, raw1) r2 := req(accountRef, model.PurposeEmailChange, "b@example.com", time.Hour).WithIdempotencyKey("same-key") raw2, err := db.Create(ctx, r2) require.NoError(t, err) require.NotEmpty(t, raw2) assert.NotEqual(t, raw1, raw2) t1, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw1) require.NoError(t, err) assert.Equal(t, "a@example.com", t1.Target) t2, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw2) require.NoError(t, err) assert.Equal(t, "b@example.com", t2.Target) } func TestCreate_IdempotencySurvivesCallbackRetry(t *testing.T) { db := newTestVerificationDBWithFactory(t, &retryingTxFactory{}) ctx := context.Background() accountRef := bson.NewObjectID() r := req(accountRef, model.PurposePasswordReset, "", time.Hour). WithCooldown(time.Minute). WithIdempotencyKey("retry-safe") raw, err := db.Create(ctx, r) require.NoError(t, err) require.NotEmpty(t, raw) repo := db.Repository.(*memoryTokenRepository) repo.mu.Lock() require.Len(t, repo.data, 1) for _, tok := range repo.data { require.NotNil(t, tok.IdempotencyKey) assert.Equal(t, "retry-safe", *tok.IdempotencyKey) assert.Nil(t, tok.UsedAt) assert.Equal(t, tok.VerifyTokenHash, tokenHash(raw)) } repo.mu.Unlock() }