Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions internal/core/redis_sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package core

import (
"testing"

"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
)

func setupTestRedis(t *testing.T) *miniredis.Miniredis {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}

client := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
})

UserDB = client
TokenDB = client
RateLimitDB = client

return mr
}

func TestRemoveIPSessionIndex(t *testing.T) {
mr := setupTestRedis(t)
defer mr.Close()

tests := []struct {
name string
ip string
token string
setup func()
checkFunc func(*testing.T)
}{
{
name: "Remove existing IP session index",
ip: "192.168.1.1",
token: "token1",
setup: func() {
TokenDB.SAdd(Ctx, "ip_sessions:192.168.1.1", "token1")
},
checkFunc: func(t *testing.T) {
res := TokenDB.SIsMember(Ctx, "ip_sessions:192.168.1.1", "token1").Val()
assert.False(t, res)
},
},
{
name: "Remove non-existing IP session index",
ip: "192.168.1.2",
token: "token2",
setup: func() {},
checkFunc: func(t *testing.T) {
res := TokenDB.SIsMember(Ctx, "ip_sessions:192.168.1.2", "token2").Val()
assert.False(t, res)
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mr.FlushAll()
tt.setup()
RemoveIPSessionIndex(tt.ip, tt.token)
tt.checkFunc(t)
})
}
}

func TestSyncSessionIndexes(t *testing.T) {
mr := setupTestRedis(t)
defer mr.Close()

tests := []struct {
name string
setup func()
expected int64
checkFunc func(*testing.T)
}{
{
name: "Sync multiple sessions",
setup: func() {
TokenDB.SAdd(Ctx, "user_sessions:user1", "valid_token")
TokenDB.HSet(Ctx, "X-rauth-authtoken=valid_token", "status", "valid")

TokenDB.SAdd(Ctx, "user_sessions:user1", "invalid_token")

TokenDB.SAdd(Ctx, "ip_sessions:10.0.0.1", "valid_ip_token")
TokenDB.HSet(Ctx, "X-rauth-authtoken=valid_ip_token", "status", "valid")

TokenDB.SAdd(Ctx, "ip_sessions:10.0.0.1", "invalid_ip_token")
},
expected: 1,
checkFunc: func(t *testing.T) {
assert.False(t, TokenDB.SIsMember(Ctx, "user_sessions:user1", "invalid_token").Val())
assert.False(t, TokenDB.SIsMember(Ctx, "ip_sessions:10.0.0.1", "invalid_ip_token").Val())

assert.True(t, TokenDB.SIsMember(Ctx, "user_sessions:user1", "valid_token").Val())
assert.True(t, TokenDB.SIsMember(Ctx, "ip_sessions:10.0.0.1", "valid_ip_token").Val())
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mr.FlushAll()
tt.setup()
count := SyncSessionIndexes()
assert.Equal(t, tt.expected, count)
tt.checkFunc(t)
})
}
}

func TestReconcileIndexSets(t *testing.T) {
mr := setupTestRedis(t)
defer mr.Close()

tests := []struct {
name string
pattern string
setup func()
expected int64
checkFunc func(*testing.T)
}{
{
name: "Reconcile user sessions",
pattern: "user_sessions:*",
setup: func() {
TokenDB.SAdd(Ctx, "user_sessions:user2", "tokenA")
TokenDB.HSet(Ctx, "X-rauth-authtoken=tokenA", "status", "valid")
TokenDB.SAdd(Ctx, "user_sessions:user2", "tokenB")
},
expected: 1,
checkFunc: func(t *testing.T) {
assert.False(t, TokenDB.SIsMember(Ctx, "user_sessions:user2", "tokenB").Val())
assert.True(t, TokenDB.SIsMember(Ctx, "user_sessions:user2", "tokenA").Val())
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mr.FlushAll()
tt.setup()
count := reconcileIndexSets(tt.pattern)
assert.Equal(t, tt.expected, count)
tt.checkFunc(t)
})
}
}
92 changes: 92 additions & 0 deletions internal/core/security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,95 @@ func TestValidateUsername(t *testing.T) {
})
}
}


func TestSetBcryptCost(t *testing.T) {
originalCost := bcryptCost
defer func() { bcryptCost = originalCost }()

tests := []struct {
name string
cost int
expected int
}{
{
name: "Valid cost",
cost: 10,
expected: 10,
},
{
name: "Too low cost",
cost: 2,
expected: 12,
},
{
name: "Too high cost",
cost: 32,
expected: 12,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
SetBcryptCost(tt.cost)
assert.Equal(t, tt.expected, bcryptCost)
})
}
}

func TestFormatDevice(t *testing.T) {
tests := []struct {
name string
ua string
platform string
mobile string
model string
expected string
}{
{
name: "Full device info",
ua: "Mozilla/5.0 (Linux; Android 10; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.162 Mobile Safari/537.36",
platform: "Android",
mobile: "?1",
model: "SM-G981B",
expected: "Chrome on Android [SM-G981B] [Mobile]",
},
{
name: "Missing model and mobile indicator",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/112.0",
platform: "Windows",
mobile: "?0",
model: "",
expected: "Firefox on Windows",
},
{
name: "Unknown model",
ua: "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36",
platform: "Android",
mobile: "?1",
model: "Unknown",
expected: "Chrome on Android [Mobile]", // Note: FormatUserAgent might parse differently based on exact UA, adjust expected if needed
},
{
name: "Mobile already in UA",
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1",
platform: "iOS",
mobile: "?1",
model: "iPhone",
expected: "Safari on iOS [iPhone] [Mobile]", // 'iPhone' UA parsing might already include [Mobile], if so it shouldn't duplicate
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := FormatDevice(tt.ua, tt.platform, tt.mobile, tt.model)
// we are doing loose checks for mobile because of underlying FormatUserAgent behavior
if tt.mobile == "?1" {
assert.Contains(t, res, "[Mobile]")
}
if tt.model != "" && tt.model != "Unknown" {
assert.Contains(t, res, tt.model)
}
})
}
}
121 changes: 121 additions & 0 deletions internal/handlers/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,124 @@ func TestProfileHandler_ChangePassword(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}


func TestProfileHandler_GenerateRecoveryCodes(t *testing.T) {
setupHandlersTest(t)
h := &ProfileHandler{Cfg: &core.Config{ServerSecret: "01234567890123456789012345678901"}}
e := echo.New()

t.Run("Generate recovery codes missing 2fa", func(t *testing.T) {
core.UserDB.Del(core.Ctx, "user:profileuser")

f := make(url.Values)
f.Set("otp_code", "123456")

c, _ := createTestContext(e, http.MethodPost, "/rauthprofile/recovery", f)
c.Set("username", "profileuser")

err := h.GenerateRecoveryCodes(c)
assert.Error(t, err)
he, ok := err.(*echo.HTTPError)
assert.True(t, ok)
assert.Equal(t, http.StatusBadRequest, he.Code)
})
}

func TestProfileHandler_RenamePasskey(t *testing.T) {
setupHandlersTest(t)
h := &ProfileHandler{Cfg: &core.Config{}}
e := echo.New()

t.Run("Rename passkey successfully", func(t *testing.T) {
f := make(url.Values)
f.Set("id", "dGVzdGlk")
f.Set("nickname", "New Name")

c, _ := createTestContext(e, http.MethodPost, "/rauthprofile/passkeys/rename", f)
c.Set("username", "profileuser")

// Pre-populate credential properly mapped
core.UserDB.HSet(core.Ctx, "user:profileuser:webauthn_creds_v2", "dGVzdGlk", `{"id":"dGVzdGlk","publicKey":"","attestationType":"","authenticator":{"aaguid":"","signCount":0,"cloneWarning":false}}`)


err := h.RenamePasskey(c)
assert.NoError(t, err)
})
}

func TestProfileHandler_RevokePasskey(t *testing.T) {
setupHandlersTest(t)
h := &ProfileHandler{Cfg: &core.Config{}}
e := echo.New()

t.Run("Revoke passkey successfully", func(t *testing.T) {
f := make(url.Values)
f.Set("id", "dGVzdGlk")

c, _ := createTestContext(e, http.MethodPost, "/rauthprofile/passkeys/revoke", f)
c.Set("username", "profileuser")

core.UserDB.HSet(core.Ctx, "user:profileuser:webauthn_creds_v2", "dGVzdGlk", `{"id":"dGVzdGlk","publicKey":"","attestationType":"","authenticator":{"aaguid":"","signCount":0,"cloneWarning":false}}`)

err := h.RevokePasskey(c)
assert.NoError(t, err)
})
}

func TestProfileHandler_DisableTOTP(t *testing.T) {
setupHandlersTest(t)
h := &ProfileHandler{Cfg: &core.Config{}}
e := echo.New()

t.Run("Disable TOTP missing secret", func(t *testing.T) {
core.UserDB.Del(core.Ctx, "user:profileuser")

f := make(url.Values)
f.Set("otp_code", "123456")

c, _ := createTestContext(e, http.MethodPost, "/rauthprofile/2fa/disable", f)
c.Set("username", "profileuser")

err := h.DisableTOTP(c)
assert.NoError(t, err)
})
}

func TestProfileHandler_TerminateAllOtherSessions(t *testing.T) {
setupHandlersTest(t)
h := &ProfileHandler{Cfg: &core.Config{}}
e := echo.New()

t.Run("Terminate other sessions successfully", func(t *testing.T) {
pwd := "SecurePass123!"
hashedPwd, _ := core.HashPassword(pwd)

f := make(url.Values)
f.Set("password", pwd)

c, _ := createTestContext(e, http.MethodPost, "/rauthprofile/sessions/terminate", f)
c.Set("username", "profileuser")
c.Set("token", "currenttoken")

core.UserDB.HSet(core.Ctx, "user:profileuser", "password", string(hashedPwd))
core.TokenDB.SAdd(core.Ctx, "user_sessions:profileuser", "currenttoken", "othertoken")
core.TokenDB.HSet(core.Ctx, "X-rauth-authtoken=othertoken", "status", "valid")

err := h.TerminateAllOtherSessions(c)
assert.NoError(t, err)

assert.False(t, core.TokenDB.SIsMember(core.Ctx, "user_sessions:profileuser", "othertoken").Val())
assert.True(t, core.TokenDB.SIsMember(core.Ctx, "user_sessions:profileuser", "currenttoken").Val())
})
}

func TestProfileHandler_validateTOTP(t *testing.T) {
setupHandlersTest(t)
h := &ProfileHandler{Cfg: &core.Config{}}

t.Run("Missing OTP", func(t *testing.T) {
err := h.validateTOTP("user", "", "secret")
assert.Error(t, err)
})
}
Loading
Loading