qnote/server/internal/service/util_test.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)
}