package verification import ( "context" "errors" "strings" "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_ReturnsRawToken(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) assert.NotEmpty(t, raw) } func TestCreate_TokenCanBeConsumed(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) assert.Equal(t, model.PurposePasswordReset, tok.Purpose) assert.NotNil(t, tok.UsedAt) } func TestConsume_ReturnsCorrectFields(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() raw, err := db.Create(ctx, req(accountRef, model.PurposeEmailChange, "new@example.com", time.Hour)) require.NoError(t, err) tok, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", raw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) assert.Equal(t, model.PurposeEmailChange, tok.Purpose) assert.Equal(t, "new@example.com", tok.Target) } func TestConsume_SecondConsumeFailsAlreadyUsed(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) require.NoError(t, err) _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "second consume should fail with already-used after usedAt is set") } func TestConsume_ExpiredTokenFails(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() // Create with a TTL that is already in the past. raw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", -time.Hour)) require.NoError(t, err) _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenExpired), "expired token should return explicit expiry error") } func TestConsume_UnknownTokenFails(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() _, err := db.Consume(ctx, bson.NilObjectID, "", "", "nonexistent-token-value") require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenNotFound)) } func TestConsume_AccountActivationWithoutAccountRef(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() raw, err := db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) require.NoError(t, err) tok, err := db.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, "", raw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) assert.Equal(t, model.PurposeAccountActivation, tok.Purpose) } func TestCreate_InvalidatesPreviousToken(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() oldRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) newRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) assert.NotEqual(t, oldRaw, newRaw, "new token should differ from old one") // Old token is no longer consumable — invalidated (usedAt set) by the second Create. _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", oldRaw) require.Error(t, err) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "old token should return already-used after invalidation") // New token works fine. tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", newRaw) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } func TestCreate_AccountActivationResendWithoutIdempotency_ReissuesToken(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() // First issue for the same verification scope. 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, 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() accountRef := bson.NewObjectID() first, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) second, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) third, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", first) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "first should be invalidated/used") _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", second) assert.True(t, errors.Is(err, ErrTokenAlreadyUsed), "second should be invalidated/used") tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", third) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } func TestCreate_DifferentPurposeNotInvalidated(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() resetRaw, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) // Creating an activation token should NOT invalidate the password-reset token. _, err = db.Create(ctx, req(accountRef, model.PurposeAccountActivation, "", time.Hour)) require.NoError(t, err) tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", resetRaw) require.NoError(t, err) assert.Equal(t, model.PurposePasswordReset, tok.Purpose) } func TestCreate_DifferentTargetNotInvalidated(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() firstRaw, err := db.Create(ctx, req(accountRef, model.PurposeEmailChange, "a@example.com", time.Hour)) require.NoError(t, err) // Creating a token for a different target email should NOT invalidate the first. _, err = db.Create(ctx, req(accountRef, model.PurposeEmailChange, "b@example.com", time.Hour)) require.NoError(t, err) tok, err := db.Consume(ctx, accountRef, model.PurposeEmailChange, "", firstRaw) require.NoError(t, err) assert.Equal(t, "a@example.com", tok.Target) } func TestCreate_DifferentAccountNotInvalidated(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() account1 := bson.NewObjectID() account2 := bson.NewObjectID() raw1, err := db.Create(ctx, req(account1, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) _, err = db.Create(ctx, req(account2, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) tok, err := db.Consume(ctx, account1, model.PurposePasswordReset, "", raw1) require.NoError(t, err) assert.Equal(t, account1, tok.AccountRef) } func TestCreate_AlreadyUsedTokenNotInvalidatedAgain(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() // Create and consume first token. raw1, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) _, err = db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw1) require.NoError(t, err) // Create second — the already-consumed token should have usedAt set, // so the invalidation query (usedAt == nil) should skip it. // This tests that the PatchMany filter correctly excludes already-used tokens. raw2, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw2) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) } func TestCreate_ExpiredTokenNotInvalidated(t *testing.T) { db := newTestVerificationDB(t) ctx := context.Background() accountRef := bson.NewObjectID() // Create a token that is already expired. _, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", -time.Hour)) require.NoError(t, err) // Create a fresh one — invalidation should skip the expired token (expiresAt > now filter). raw2, err := db.Create(ctx, req(accountRef, model.PurposePasswordReset, "", time.Hour)) require.NoError(t, err) tok, err := db.Consume(ctx, accountRef, model.PurposePasswordReset, "", raw2) require.NoError(t, err) assert.Equal(t, accountRef, tok.AccountRef) }