470 lines
11 KiB
Go
470 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
)
|
|
|
|
type MockHTTPClient struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *MockHTTPClient) Get(url string) (*http.Response, error) {
|
|
args := m.Called(url)
|
|
return args.Get(0).(*http.Response), args.Error(1)
|
|
}
|
|
|
|
func TestValidatePassword(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
password string
|
|
wantErr string
|
|
mockHTTP func(*MockHTTPClient)
|
|
}{
|
|
{
|
|
name: "too short",
|
|
password: strings.Repeat("a", minPasswordLength-1),
|
|
wantErr: fmt.Sprintf("password must be at least %d characters", minPasswordLength),
|
|
},
|
|
{
|
|
name: "too long",
|
|
password: strings.Repeat("a", maxPasswordLength+1),
|
|
wantErr: fmt.Sprintf("password cannot be longer than %d characters", maxPasswordLength),
|
|
},
|
|
{
|
|
name: "low entropy",
|
|
password: strings.Repeat("a", minPasswordLength),
|
|
wantErr: "password is too weak",
|
|
},
|
|
{
|
|
name: "valid password",
|
|
password: "SecurePassw0rd!123",
|
|
wantErr: "",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Mock HTTP client if needed
|
|
if tc.mockHTTP != nil {
|
|
mockClient := new(MockHTTPClient)
|
|
tc.mockHTTP(mockClient)
|
|
}
|
|
|
|
err := validatePassword(tc.password)
|
|
if tc.wantErr == "" {
|
|
assert.NoError(t, err)
|
|
} else {
|
|
assert.ErrorContains(t, err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsPasswordCompromised(t *testing.T) {
|
|
t.Run("known compromised", func(t *testing.T) {
|
|
compromised, err := isPasswordCompromised("password123456")
|
|
assert.NoError(t, err)
|
|
assert.True(t, compromised)
|
|
})
|
|
|
|
t.Run("randomly generated", func(t *testing.T) {
|
|
randomStr := genRandomString(t, 12)
|
|
compromised, err := isPasswordCompromised(randomStr)
|
|
assert.NoError(t, err)
|
|
assert.False(t, compromised)
|
|
})
|
|
}
|
|
|
|
func TestPasswordEntropyCalculation(t *testing.T) {
|
|
tests := []struct {
|
|
password string
|
|
entropy float64
|
|
}{
|
|
{
|
|
"password",
|
|
24,
|
|
},
|
|
{
|
|
"SecurePassw0rd!123",
|
|
73,
|
|
},
|
|
{
|
|
"aaaaaaaaaaaaaaaa",
|
|
5,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.password, func(t *testing.T) {
|
|
entropy := calcPasswordEntropy(tc.password)
|
|
assert.InDelta(t, tc.entropy, entropy, 1.0)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateUsername(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "too short",
|
|
input: strings.Repeat("a", minUsernameLength-1),
|
|
wantErr: fmt.Sprintf("username must be at least %d characters", minUsernameLength),
|
|
},
|
|
{
|
|
name: "too long",
|
|
input: strings.Repeat("a", maxUsernameLength+1),
|
|
wantErr: fmt.Sprintf("username cannot be longer than %d characters", maxUsernameLength),
|
|
},
|
|
{
|
|
name: "invalid characters",
|
|
input: "user@name",
|
|
wantErr: "username can only contain numbers, letters, and underscores",
|
|
},
|
|
{
|
|
name: "valid username",
|
|
input: "valid_user123",
|
|
wantErr: "",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := validateUsername(tc.input)
|
|
if tc.wantErr == "" {
|
|
assert.NoError(t, err)
|
|
} else {
|
|
assert.ErrorContains(t, err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeUsername(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{
|
|
name: "trim whitespace",
|
|
input: " test_user ",
|
|
want: "test_user",
|
|
},
|
|
{
|
|
name: "lowercase",
|
|
input: "TestUser",
|
|
want: "testuser",
|
|
},
|
|
{
|
|
name: "mixed case and spaces",
|
|
input: " UserName123 ",
|
|
want: "username123",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := normalizeUsername(tc.input)
|
|
assert.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetPaginationParams(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
expectedLimit int32
|
|
expectedOffset int32
|
|
}{
|
|
{
|
|
name: "defaults",
|
|
query: "",
|
|
expectedLimit: 50,
|
|
expectedOffset: 0,
|
|
},
|
|
{
|
|
name: "valid limit and offset",
|
|
query: "limit=25&offset=30",
|
|
expectedLimit: 25,
|
|
expectedOffset: 30,
|
|
},
|
|
{
|
|
name: "invalid limit",
|
|
query: "limit=abc&offset=5",
|
|
expectedLimit: 50,
|
|
expectedOffset: 5,
|
|
},
|
|
{
|
|
name: "limit zero",
|
|
query: "limit=0",
|
|
expectedLimit: 50,
|
|
expectedOffset: 0,
|
|
},
|
|
{
|
|
name: "limit negative",
|
|
query: "limit=-5",
|
|
expectedLimit: 50,
|
|
expectedOffset: 0,
|
|
},
|
|
{
|
|
name: "offset negative",
|
|
query: "offset=-10",
|
|
expectedLimit: 50,
|
|
expectedOffset: 0,
|
|
},
|
|
{
|
|
name: "invalid offset",
|
|
query: "offset=xyz",
|
|
expectedLimit: 50,
|
|
expectedOffset: 0,
|
|
},
|
|
{
|
|
name: "valid offset zero",
|
|
query: "offset=0",
|
|
expectedLimit: 50,
|
|
expectedOffset: 0,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req, err := http.NewRequest("GET", "/test?"+tc.query, nil)
|
|
assert.Nil(t, err)
|
|
limit, offset := getPaginationParams(req)
|
|
assert.Equal(t, tc.expectedLimit, limit)
|
|
assert.Equal(t, tc.expectedOffset, offset)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSHA1ContentHash(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
title string
|
|
content string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "empty strings",
|
|
title: "",
|
|
content: "",
|
|
expected: "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709",
|
|
},
|
|
{
|
|
name: "title only",
|
|
title: "hello",
|
|
content: "",
|
|
expected: "AAF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D",
|
|
},
|
|
{
|
|
name: "content only",
|
|
title: "",
|
|
content: "world",
|
|
expected: "7C211433F02071597741E6FF5A8EA34789ABBF43",
|
|
},
|
|
{
|
|
name: "both title and content",
|
|
title: "hello",
|
|
content: "world",
|
|
expected: "6ADFB183A4A2C94A2F92DAB5ADE762A47889A5A1",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := sha1ContentHash(tc.title, tc.content)
|
|
assert.Equal(t, tc.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseTitleAbsoluteExpiration(t *testing.T) {
|
|
threeDaysLater := time.Now().AddDate(0, 0, 3)
|
|
threeDaysInPast := time.Now().AddDate(0, 0, -3)
|
|
overMaxYearsLater := time.Now().AddDate(maxFutureExpirationYears+1, 0, 0)
|
|
|
|
tests := []struct {
|
|
name string
|
|
title string
|
|
expected *time.Time
|
|
err error
|
|
}{
|
|
{
|
|
name: "Valid absolute date",
|
|
title: fmt.Sprintf("@exp:%s Task", formatAbsDate(t, threeDaysLater)),
|
|
expected: timePtr(t, createEndOfDay(t, threeDaysLater)),
|
|
err: nil,
|
|
},
|
|
{
|
|
name: "Valid absolute date with expires keyword",
|
|
title: fmt.Sprintf("@expires:%s Task", formatAbsDate(t, threeDaysLater)),
|
|
expected: timePtr(t, createEndOfDay(t, threeDaysLater)),
|
|
err: nil,
|
|
},
|
|
{
|
|
name: "Absolute date in the past",
|
|
title: fmt.Sprintf("@exp:%s Task", formatAbsDate(t, threeDaysInPast)),
|
|
expected: nil,
|
|
err: ErrPastExpirationDate,
|
|
},
|
|
{
|
|
name: "Absolute date too far in the future",
|
|
title: fmt.Sprintf("@exp:%s Task", formatAbsDate(t, overMaxYearsLater)),
|
|
expected: nil,
|
|
err: ErrExpirationTooFar,
|
|
},
|
|
{
|
|
name: "Invalid absolute date format",
|
|
title: "@exp:2028-13-31 Task", // Invalid month
|
|
expected: nil,
|
|
err: ErrInvalidExpirationDate,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result, err := parseTitleExpiration(&tc.title)
|
|
if tc.err != nil {
|
|
if !errors.Is(err, tc.err) {
|
|
t.Errorf("Expected error %s, got %s", tc.err, err)
|
|
}
|
|
} else if err != nil {
|
|
t.Errorf("Unexpected error: %s", err)
|
|
}
|
|
|
|
if tc.expected == nil && result != nil {
|
|
t.Errorf("Expected nil result, got %s", *result)
|
|
} else if tc.expected != nil && result != nil {
|
|
if !tc.expected.Equal(*result) {
|
|
t.Errorf("Expected %s, got %s", *tc.expected, *result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseTitleRelativeExpiration(t *testing.T) {
|
|
threeDaysLater := time.Now().AddDate(0, 0, 3)
|
|
threeWeeksLater := time.Now().AddDate(0, 0, 3*7)
|
|
threeMonthsLater := time.Now().AddDate(0, 3, 0)
|
|
threeYearsLater := time.Now().AddDate(3, 0, 0)
|
|
|
|
tests := []struct {
|
|
name string
|
|
title string
|
|
expected *time.Time
|
|
err error
|
|
}{
|
|
{
|
|
name: "Valid relative date format with days",
|
|
title: "@exp:+3d Task",
|
|
expected: timePtr(t, createEndOfDay(t, threeDaysLater)),
|
|
err: nil,
|
|
},
|
|
{
|
|
name: "Valid relative date format with weeks",
|
|
title: "@exp:+3w Task",
|
|
expected: timePtr(t, createEndOfDay(t, threeWeeksLater)),
|
|
err: nil,
|
|
},
|
|
{
|
|
name: "Valid relative date format with months",
|
|
title: "@exp:+3m Task",
|
|
expected: timePtr(t, createEndOfDay(t, threeMonthsLater)),
|
|
err: nil,
|
|
},
|
|
{
|
|
name: "Valid relative date format with years",
|
|
title: "@exp:+3y Task",
|
|
expected: timePtr(t, createEndOfDay(t, threeYearsLater)),
|
|
err: nil,
|
|
},
|
|
{
|
|
name: "Invalid relative amount (zero)",
|
|
title: "@exp:+0d Task",
|
|
expected: nil,
|
|
err: ErrInvalidExpirationDate,
|
|
},
|
|
{
|
|
name: "Invalid relative amount (negative)",
|
|
title: "@exp:-1d Task",
|
|
expected: nil,
|
|
err: ErrNoExpirationDateFound, // Doesn't match either of the RegExs
|
|
},
|
|
{
|
|
name: "Invalid relative unit",
|
|
title: "@exp:+30a Task",
|
|
expected: nil,
|
|
err: ErrNoExpirationDateFound, // Doesn't match either of the RegExs
|
|
},
|
|
{
|
|
name: "Relative date too far in the future",
|
|
title: fmt.Sprintf("@exp:+%dy Task", maxFutureExpirationYears+1),
|
|
expected: nil,
|
|
err: ErrExpirationTooFar,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result, err := parseTitleExpiration(&tc.title)
|
|
if tc.err != nil {
|
|
if !errors.Is(err, tc.err) {
|
|
t.Errorf("Expected error %s, got %s", tc.err, err)
|
|
}
|
|
} else if err != nil {
|
|
t.Errorf("Unexpected error: %s", err)
|
|
}
|
|
|
|
if tc.expected == nil && result != nil {
|
|
t.Errorf("Expected nil result, got %s", *result)
|
|
} else if tc.expected != nil && result != nil {
|
|
if !tc.expected.Equal(*result) {
|
|
t.Errorf("Expected %s, got %s", *tc.expected, *result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func createEndOfDay(t *testing.T, tm time.Time) time.Time {
|
|
t.Helper()
|
|
return time.Date(tm.Year(), tm.Month(), tm.Day(), 23, 59, 59, 0, time.UTC)
|
|
}
|
|
|
|
func timePtr(t *testing.T, tm time.Time) *time.Time {
|
|
t.Helper()
|
|
return &tm
|
|
}
|
|
|
|
func formatAbsDate(t *testing.T, tm time.Time) string {
|
|
t.Helper()
|
|
return tm.Format("2006-01-02")
|
|
}
|
|
|
|
func genRandomString(t *testing.T, length int) string {
|
|
t.Helper()
|
|
|
|
const charset = "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
seededRand := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
b := make([]byte, length)
|
|
for i := range b {
|
|
b[i] = charset[seededRand.Intn(len(charset))]
|
|
}
|
|
|
|
return string(b)
|
|
}
|