mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
619 lines
18 KiB
Go
619 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/bootstrap"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
)
|
|
|
|
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
|
var matched *http.Cookie
|
|
for _, cookie := range cookies {
|
|
if cookie.Name == name {
|
|
matched = cookie
|
|
}
|
|
}
|
|
return matched
|
|
}
|
|
|
|
func TestQuickSecuritySetupRejectsUnauthenticatedForce(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
hashed, err := internalauth.HashPassword("OldPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: t.TempDir(),
|
|
ConfigPath: t.TempDir(),
|
|
}
|
|
|
|
router := &Router{config: cfg}
|
|
handler := handleQuickSecuritySetupFixed(router)
|
|
|
|
// Ensure rate limiter does not block test runs
|
|
authLimiter.Reset("198.51.100.42")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup",
|
|
strings.NewReader(`{"username":"newadmin","password":"NewPassword!1","apiToken":"abcd","force":true}`))
|
|
req.RemoteAddr = "198.51.100.42:54321"
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 unauthorized, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestQuickSecuritySetupRequiresBootstrapToken(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
dataDir := t.TempDir()
|
|
|
|
cfg := &config.Config{
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
}
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: config.NewConfigPersistence(cfg.DataPath),
|
|
}
|
|
router.initializeBootstrapToken()
|
|
|
|
tokenPath := filepath.Join(cfg.DataPath, bootstrapTokenFilename)
|
|
bootstrapToken, _, _, err := bootstrap.Load(cfg.DataPath)
|
|
if err != nil {
|
|
t.Fatalf("load bootstrap token: %v", err)
|
|
}
|
|
|
|
handler := handleQuickSecuritySetupFixed(router)
|
|
|
|
authLimiter.Reset("198.51.100.80")
|
|
|
|
payload := `{"username":"bootstrap","password":"StrongPass!1","apiToken":"` + strings.Repeat("aa", 32) + `"}`
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
|
|
req.RemoteAddr = "198.51.100.80:54321"
|
|
rr := httptest.NewRecorder()
|
|
handler(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 unauthorized without bootstrap token, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
authLimiter.Reset("198.51.100.80")
|
|
|
|
reqWith := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(payload))
|
|
reqWith.RemoteAddr = "198.51.100.80:54321"
|
|
reqWith.Header.Set(bootstrapTokenHeader, bootstrapToken)
|
|
|
|
rrWith := httptest.NewRecorder()
|
|
handler(rrWith, reqWith)
|
|
if rrWith.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 OK with valid bootstrap token, got %d (%s)", rrWith.Code, rrWith.Body.String())
|
|
}
|
|
|
|
cookies := rrWith.Result().Cookies()
|
|
sessionCookie := findCookie(cookies, sessionCookieName(false))
|
|
if sessionCookie == nil || strings.TrimSpace(sessionCookie.Value) == "" {
|
|
t.Fatalf("expected quick setup to establish a browser session cookie")
|
|
}
|
|
if !ValidateSession(sessionCookie.Value) {
|
|
t.Fatalf("expected established session to validate")
|
|
}
|
|
if got := GetSessionUsername(sessionCookie.Value); got != "bootstrap" {
|
|
t.Fatalf("session username=%q, want %q", got, "bootstrap")
|
|
}
|
|
|
|
csrfCookie := findCookie(cookies, CookieNameCSRF)
|
|
if csrfCookie == nil || strings.TrimSpace(csrfCookie.Value) == "" {
|
|
t.Fatalf("expected quick setup to issue a CSRF cookie")
|
|
}
|
|
|
|
if _, err := os.Stat(tokenPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("expected bootstrap token file to be removed after successful setup, got err=%v", err)
|
|
}
|
|
if router.bootstrapTokenHash != "" {
|
|
t.Fatalf("expected bootstrap token hash to be cleared after successful setup")
|
|
}
|
|
}
|
|
|
|
func TestValidateBootstrapTokenEndpoint(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
dataDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
}
|
|
|
|
router := &Router{config: cfg}
|
|
router.initializeBootstrapToken()
|
|
|
|
tokenPath := filepath.Join(cfg.DataPath, bootstrapTokenFilename)
|
|
token, _, _, err := bootstrap.Load(cfg.DataPath)
|
|
if err != nil {
|
|
t.Fatalf("load bootstrap token: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(router.handleValidateBootstrapToken)
|
|
|
|
// GET not allowed
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/security/validate-bootstrap-token", nil)
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405 for GET, got %d", rr.Code)
|
|
}
|
|
|
|
// Missing token payload
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader("{}"))
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for missing token, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Invalid token
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"deadbeef"}`))
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for invalid token, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Valid token
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"`+token+`"}`))
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204 for valid token, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Bootstrap token should remain on disk after validation
|
|
if _, err := os.Stat(tokenPath); err != nil {
|
|
t.Fatalf("bootstrap token should remain after validation, got err=%v", err)
|
|
}
|
|
|
|
// Once token removed, endpoint should report conflict
|
|
router.clearBootstrapToken()
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{"token":"`+token+`"}`))
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 when bootstrap token unavailable, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestQuickSecuritySetupAllowsRecoveryTokenRotation(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
resetRecoveryStore()
|
|
|
|
dataDir := t.TempDir()
|
|
|
|
hashed, err := internalauth.HashPassword("OldPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
}
|
|
|
|
persistence := config.NewConfigPersistence(cfg.DataPath)
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: persistence,
|
|
}
|
|
|
|
InitRecoveryTokenStore(cfg.DataPath)
|
|
token, err := GetRecoveryTokenStore().GenerateRecoveryToken(5 * time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("generate recovery token: %v", err)
|
|
}
|
|
|
|
authLimiter.Reset("127.0.0.1")
|
|
|
|
body := `{"username":"rotated","password":"RotatedPassword!1","apiToken":"deadbeef","force":true}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body))
|
|
req.Header.Set("X-Recovery-Token", token)
|
|
req.RemoteAddr = "127.0.0.1:54321"
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler := handleQuickSecuritySetupFixed(router)
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 OK, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
if cfg.AuthUser != "rotated" {
|
|
t.Fatalf("expected AuthUser to be rotated, got %q", cfg.AuthUser)
|
|
}
|
|
if !internalauth.CheckPasswordHash("RotatedPassword!1", cfg.AuthPass) {
|
|
t.Fatalf("stored password hash does not match new password")
|
|
}
|
|
if len(cfg.APITokens) != 1 {
|
|
t.Fatalf("expected one API token, got %d", len(cfg.APITokens))
|
|
}
|
|
}
|
|
|
|
func TestQuickSecuritySetupRequiresSettingsScopeForTokens(t *testing.T) {
|
|
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
|
|
resetTrustedProxyConfig()
|
|
|
|
dataDir := t.TempDir()
|
|
hashed, err := internalauth.HashPassword("ExistingPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
rawToken := strings.Repeat("ab", 32) // 64 hex chars
|
|
record, err := config.NewAPITokenRecord(rawToken, "limited", []string{config.ScopeAgentReport})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
cfg.SortAPITokens()
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: config.NewConfigPersistence(cfg.DataPath),
|
|
}
|
|
|
|
authLimiter.Reset("203.0.113.10")
|
|
|
|
body := `{"username":"rotated","password":"RotatedPassword!1","apiToken":"deadbeef","force":true}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body))
|
|
req.RemoteAddr = "203.0.113.10:1234"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
|
|
rr := httptest.NewRecorder()
|
|
handler := handleQuickSecuritySetupFixed(router)
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 forbidden for missing scope, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRegenerateAPITokenRequiresSettingsScope(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
hashed, err := internalauth.HashPassword("ExistingPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
rawToken := strings.Repeat("cd", 32)
|
|
record, err := config.NewAPITokenRecord(rawToken, "limited", []string{config.ScopeAgentReport})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
cfg.SortAPITokens()
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: config.NewConfigPersistence(cfg.DataPath),
|
|
}
|
|
|
|
authLimiter.Reset("198.51.100.7")
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil)
|
|
req.RemoteAddr = "198.51.100.7:44321"
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.HandleRegenerateAPIToken(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 forbidden for missing scope, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestValidateAPITokenRequiresSettingsScope(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
hashed, err := internalauth.HashPassword("ExistingPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
rawToken := strings.Repeat("ef", 32)
|
|
record, err := config.NewAPITokenRecord(rawToken, "limited", []string{config.ScopeAgentReport})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
cfg.SortAPITokens()
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
persistence: config.NewConfigPersistence(cfg.DataPath),
|
|
}
|
|
|
|
authLimiter.Reset("198.51.100.8")
|
|
body := `{"token":"abcdef"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(body))
|
|
req.RemoteAddr = "198.51.100.8:54321"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.HandleValidateAPIToken(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 forbidden for missing scope, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestResetLockoutRequiresSettingsScope(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
hashed, err := internalauth.HashPassword("ExistingPassword!1")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
|
|
rawToken := strings.Repeat("12", 32)
|
|
record, err := config.NewAPITokenRecord(rawToken, "limited", []string{config.ScopeAgentReport})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
AuthPass: hashed,
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
cfg.SortAPITokens()
|
|
|
|
router := &Router{
|
|
config: cfg,
|
|
}
|
|
|
|
authLimiter.Reset("198.51.100.9")
|
|
body := `{"identifier":"admin"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/reset-lockout", strings.NewReader(body))
|
|
req.RemoteAddr = "198.51.100.9:54321"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-API-Token", rawToken)
|
|
|
|
rr := httptest.NewRecorder()
|
|
router.handleResetLockout(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 forbidden for missing scope, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestEnsureSettingsWriteScopeWithValidScope(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
|
|
rawToken := strings.Repeat("ab", 32)
|
|
record, err := config.NewAPITokenRecord(rawToken, "admin-token", []string{config.ScopeSettingsWrite})
|
|
if err != nil {
|
|
t.Fatalf("new token record: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{
|
|
DataPath: dataDir,
|
|
ConfigPath: dataDir,
|
|
APITokens: []config.APITokenRecord{*record},
|
|
}
|
|
cfg.SortAPITokens()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/test", nil)
|
|
attachAPITokenRecord(req, record)
|
|
|
|
rr := httptest.NewRecorder()
|
|
result := ensureSettingsWriteScope(cfg, rr, req)
|
|
|
|
if !result {
|
|
t.Error("ensureSettingsWriteScope should return true when token has settings:write scope")
|
|
}
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("expected no error response, got status %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestEnsureSettingsWriteScopeRejectsNonAdminSession(t *testing.T) {
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
}
|
|
|
|
sessionToken := "settings-write-member-" + time.Now().Format("150405.000000000")
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "member")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/test", nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
|
|
rr := httptest.NewRecorder()
|
|
result := ensureSettingsWriteScope(cfg, rr, req)
|
|
|
|
if result {
|
|
t.Fatal("ensureSettingsWriteScope should reject non-admin session users")
|
|
}
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for non-admin session, got %d", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Admin privileges required") {
|
|
t.Fatalf("expected admin privileges error, got %q", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestEnsureSettingsWriteScopeAllowsConfiguredAdminSession(t *testing.T) {
|
|
cfg := &config.Config{
|
|
AuthUser: "admin",
|
|
}
|
|
|
|
sessionToken := "settings-write-admin-" + time.Now().Format("150405.000000001")
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "admin")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/test", nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
|
|
rr := httptest.NewRecorder()
|
|
result := ensureSettingsWriteScope(cfg, rr, req)
|
|
|
|
if !result {
|
|
t.Fatal("ensureSettingsWriteScope should allow configured admin session users")
|
|
}
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for admin session, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestEnsureSettingsWriteScopeAllowsOrgManagerSession(t *testing.T) {
|
|
cfg := &config.Config{
|
|
AuthUser: "platform-admin",
|
|
}
|
|
|
|
sessionToken := "settings-write-owner-" + time.Now().Format("150405.000000002")
|
|
GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "legacy-owner")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/test", nil)
|
|
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
|
|
req = req.WithContext(context.WithValue(req.Context(), OrgContextKey, &models.Organization{
|
|
ID: "org-a",
|
|
DisplayName: "Org A",
|
|
OwnerUserID: "operator-owner",
|
|
Members: []models.OrganizationMember{
|
|
{UserID: "legacy-owner", Role: models.OrgRoleOwner},
|
|
{UserID: "operator-owner", Role: models.OrgRoleOwner},
|
|
},
|
|
}))
|
|
|
|
rr := httptest.NewRecorder()
|
|
result := ensureSettingsWriteScope(cfg, rr, req)
|
|
|
|
if !result {
|
|
t.Fatal("ensureSettingsWriteScope should allow org manager session users")
|
|
}
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 for org manager session, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestValidateBcryptHash(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hash string
|
|
wantErr bool
|
|
errMsg string
|
|
}{
|
|
{
|
|
name: "valid $2a$ hash",
|
|
hash: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid $2b$ hash",
|
|
hash: "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid $2y$ hash",
|
|
hash: "$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "truncated hash (59 chars)",
|
|
hash: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhW",
|
|
wantErr: true,
|
|
errMsg: "expected 60 characters, got 59",
|
|
},
|
|
{
|
|
name: "too long hash (61 chars)",
|
|
hash: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWyX",
|
|
wantErr: true,
|
|
errMsg: "expected 60 characters, got 61",
|
|
},
|
|
{
|
|
name: "empty hash",
|
|
hash: "",
|
|
wantErr: true,
|
|
errMsg: "expected 60 characters, got 0",
|
|
},
|
|
{
|
|
name: "wrong prefix (md5 style, 60 chars)",
|
|
hash: "$1$saltsalt$WabcdEFGhijKLMnopQRstuV0123456789012345123456789",
|
|
wantErr: true,
|
|
errMsg: "must start with $2a$, $2b$, or $2y$",
|
|
},
|
|
{
|
|
name: "wrong prefix ($2x$)",
|
|
hash: "$2x$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
|
|
wantErr: true,
|
|
errMsg: "must start with $2a$, $2b$, or $2y$",
|
|
},
|
|
{
|
|
name: "no prefix at all (60 chars)",
|
|
hash: "N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy1234567",
|
|
wantErr: true,
|
|
errMsg: "must start with $2a$, $2b$, or $2y$",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateBcryptHash(tt.hash)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("validateBcryptHash() expected error, got nil")
|
|
return
|
|
}
|
|
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
|
t.Errorf("validateBcryptHash() error = %q, want substring %q", err.Error(), tt.errMsg)
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("validateBcryptHash() unexpected error = %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|