155 lines
4.6 KiB
Go
155 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")
|
||
|
|
}
|