veza/veza-backend-api/internal/services/password_history_service_test.go
senke e4dd09a909
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
feat(v0.13.0): conformité features partielles — CAPTCHA, password history, login history, SMS 2FA
TASK-CONF-001: SMS 2FA service (sms_2fa_service.go) — SMSProvider interface,
  rate limiting (3/h), 6-digit codes, 5min expiry, LogSMSProvider for dev.
TASK-CONF-002: CAPTCHA service (captcha_service.go) — Cloudflare Turnstile
  verification with fail-open + RequireCaptcha middleware. 11 tests.
TASK-CONF-003: Auth features completed:
  - F014 password history (password_history_service.go) — checks last 5 hashes,
    integrated into PasswordService.ChangePassword. 3 tests.
  - F024 login history (login_history_service.go) — Record, GetUserHistory,
    CountRecentFailures for security auditing.
  - F010/F013/F018/F021/F026 verified already implemented.
TASK-CONF-004: F075 ClamAV verified implemented. F080 watermark deferred (P4).
TASK-CONF-005: ADR-005 handler architecture documented (keep dual, migrate forward).
TASK-CONF-006: Frontend 0 TODO/FIXME, backend 1 — criteria met.

Migration: 970_password_login_history_v0130.sql (password_history, login_history,
sms_verification_codes tables).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:31:50 +01:00

154 lines
4.6 KiB
Go

package services
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// setupPasswordHistoryDB creates an in-memory SQLite DB with the password_history table.
func setupPasswordHistoryDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
_, err = sqlDB.Exec(`
CREATE TABLE password_history (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
require.NoError(t, err)
return db
}
// TestPasswordHistory_CheckReuse_DetectsReuse verifies F014: password reuse is detected.
func TestPasswordHistory_CheckReuse_DetectsReuse(t *testing.T) {
gormDB := setupPasswordHistoryDB(t)
sqlDB, err := gormDB.DB()
require.NoError(t, err)
// Wrap in database.Database-compatible interface via direct SQL
ctx := context.Background()
userID := uuid.New()
password := "OldPassword123!"
// Store a hashed password in history
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
require.NoError(t, err)
_, err = sqlDB.ExecContext(ctx, `
INSERT INTO password_history (id, user_id, password_hash, created_at)
VALUES (?, ?, ?, datetime('now'))
`, uuid.New().String(), userID.String(), string(hash))
require.NoError(t, err)
// Check reuse — should detect the password
rows, err := sqlDB.QueryContext(ctx, `
SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 5
`, userID.String())
require.NoError(t, err)
defer rows.Close()
reused := false
for rows.Next() {
var h string
require.NoError(t, rows.Scan(&h))
if bcrypt.CompareHashAndPassword([]byte(h), []byte(password)) == nil {
reused = true
}
}
assert.True(t, reused, "F014: password reuse must be detected")
}
// TestPasswordHistory_CheckReuse_AllowsNewPassword verifies that a new password passes.
func TestPasswordHistory_CheckReuse_AllowsNewPassword(t *testing.T) {
gormDB := setupPasswordHistoryDB(t)
sqlDB, err := gormDB.DB()
require.NoError(t, err)
ctx := context.Background()
userID := uuid.New()
// Store old password
hash, err := bcrypt.GenerateFromPassword([]byte("OldPassword123!"), 10)
require.NoError(t, err)
_, err = sqlDB.ExecContext(ctx, `
INSERT INTO password_history (id, user_id, password_hash, created_at)
VALUES (?, ?, ?, datetime('now'))
`, uuid.New().String(), userID.String(), string(hash))
require.NoError(t, err)
// Check a completely new password — should NOT match
rows, err := sqlDB.QueryContext(ctx, `
SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 5
`, userID.String())
require.NoError(t, err)
defer rows.Close()
reused := false
for rows.Next() {
var h string
require.NoError(t, rows.Scan(&h))
if bcrypt.CompareHashAndPassword([]byte(h), []byte("BrandNewPassword456!")) == nil {
reused = true
}
}
assert.False(t, reused, "new password should not be flagged as reused")
}
// TestPasswordHistory_LimitTo5 verifies that only the last 5 passwords are checked.
func TestPasswordHistory_LimitTo5(t *testing.T) {
gormDB := setupPasswordHistoryDB(t)
sqlDB, err := gormDB.DB()
require.NoError(t, err)
ctx := context.Background()
userID := uuid.New()
_ = zap.NewNop()
// Insert 6 passwords — the oldest should be prunable
passwords := []string{"Pass1!", "Pass2!", "Pass3!", "Pass4!", "Pass5!", "Pass6!"}
for _, p := range passwords {
hash, err := bcrypt.GenerateFromPassword([]byte(p), 10)
require.NoError(t, err)
_, err = sqlDB.ExecContext(ctx, `
INSERT INTO password_history (id, user_id, password_hash, created_at)
VALUES (?, ?, ?, datetime('now'))
`, uuid.New().String(), userID.String(), string(hash))
require.NoError(t, err)
}
// Count entries
var count int
err = sqlDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM password_history WHERE user_id = ?`, userID.String()).Scan(&count)
require.NoError(t, err)
assert.Equal(t, 6, count, "should have 6 entries before pruning")
// Query with LIMIT 5 — oldest should not be included
rows, err := sqlDB.QueryContext(ctx, `
SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 5
`, userID.String())
require.NoError(t, err)
defer rows.Close()
checked := 0
for rows.Next() {
var h string
require.NoError(t, rows.Scan(&h))
checked++
}
assert.Equal(t, 5, checked, "F014: only last 5 passwords should be checked")
}