Skip to content
Merged
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
636 changes: 636 additions & 0 deletions internal/auth/backupcodes/generator_boundary_test.go

Large diffs are not rendered by default.

364 changes: 364 additions & 0 deletions internal/auth/backupcodes/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@
package backupcodes

import (
"context"
"encoding/json"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/undernetirc/cservice-api/db/mocks"
"github.com/undernetirc/cservice-api/internal/auth/password"
"github.com/undernetirc/cservice-api/models"
)

func TestGenerateBackupCodes(t *testing.T) {
Expand Down Expand Up @@ -280,3 +285,362 @@ func TestBackupCodesMetadata(t *testing.T) {
assert.Equal(t, 5, metadata.CodesRemaining)
})
}

// buildMetadataJSON is a test helper that builds the nested metadata JSON
// structure matching what the database returns.
func buildMetadataJSON(t *testing.T, codes []BackupCode, codesRemaining int) []byte {
t.Helper()
codesJSON, err := json.Marshal(codes)
require.NoError(t, err)

metadata := Metadata{
BackupCodes: string(codesJSON),
GeneratedAt: "2025-06-22T10:30:00Z",
CodesRemaining: codesRemaining,
}
metadataJSON, err := json.Marshal(metadata)
require.NoError(t, err)
return metadataJSON
}

func TestNewBackupCodeGenerator(t *testing.T) {
t.Run("creates generator with db", func(t *testing.T) {
mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

require.NotNil(t, gen)
assert.Equal(t, mockDB, gen.db)
})

t.Run("creates generator with nil db", func(t *testing.T) {
gen := NewBackupCodeGenerator(nil)

require.NotNil(t, gen)
assert.Nil(t, gen.db)
})
}

func TestGenerateAndStoreBackupCodes(t *testing.T) {
ctx := context.Background()
userID := int32(42)
updatedBy := "admin"

t.Run("successful generation and storage", func(t *testing.T) {
mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

mockDB.On("UpdateUserBackupCodes", mock.Anything, mock.MatchedBy(func(arg models.UpdateUserBackupCodesParams) bool {
return arg.ID == userID && len(arg.BackupCodes) > 0 && arg.LastUpdatedBy.String == updatedBy
})).Return(nil).Once()

codes, err := gen.GenerateAndStoreBackupCodes(ctx, userID, updatedBy)

require.NoError(t, err)
assert.Len(t, codes, BackupCodeCount)
for _, code := range codes {
assert.NoError(t, ValidateBackupCodeFormat(code))
}
mockDB.AssertExpectations(t)
})
}

func TestGenerateAndStoreBackupCodes_DBError(t *testing.T) {
ctx := context.Background()
userID := int32(42)
updatedBy := "admin"

mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

mockDB.On("UpdateUserBackupCodes", mock.Anything, mock.Anything).Return(assert.AnError).Once()

codes, err := gen.GenerateAndStoreBackupCodes(ctx, userID, updatedBy)

require.Error(t, err)
assert.Nil(t, codes)
assert.Contains(t, err.Error(), "failed to store backup codes")
mockDB.AssertExpectations(t)
}

func TestGetBackupCodes(t *testing.T) {
ctx := context.Background()
userID := int32(42)

t.Run("successful retrieval", func(t *testing.T) {
mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

storedCodes := []BackupCode{
{Hash: "$2a$04$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTU12345678"},
{Hash: "$2a$04$zyxwvutsrqponmlkjihgfeZYXWVUTSRQPONMLKJIHGFE87654321"},
}
metadataJSON := buildMetadataJSON(t, storedCodes, 2)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{
BackupCodes: metadataJSON,
}, nil).Once()

codes, err := gen.GetBackupCodes(ctx, userID)

require.NoError(t, err)
require.Len(t, codes, 2)
assert.Equal(t, storedCodes[0].Hash, codes[0].Hash)
assert.Equal(t, storedCodes[1].Hash, codes[1].Hash)
mockDB.AssertExpectations(t)
})

t.Run("empty backup codes", func(t *testing.T) {
mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{
BackupCodes: nil,
}, nil).Once()

codes, err := gen.GetBackupCodes(ctx, userID)

assert.NoError(t, err)
assert.Nil(t, codes)
mockDB.AssertExpectations(t)
})
}

func TestGetBackupCodes_NotFound(t *testing.T) {
ctx := context.Background()
userID := int32(999)

mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{
BackupCodes: []byte{},
}, nil).Once()

codes, err := gen.GetBackupCodes(ctx, userID)

assert.NoError(t, err)
assert.Nil(t, codes)
mockDB.AssertExpectations(t)
}

func TestGetBackupCodes_DBError(t *testing.T) {
ctx := context.Background()
userID := int32(42)

mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{}, assert.AnError).Once()

codes, err := gen.GetBackupCodes(ctx, userID)

require.Error(t, err)
assert.Nil(t, codes)
assert.Contains(t, err.Error(), "failed to retrieve backup codes")
mockDB.AssertExpectations(t)
}

func TestUpdateBackupCodes(t *testing.T) {
ctx := context.Background()
userID := int32(42)
updatedBy := "admin"

t.Run("successful update", func(t *testing.T) {
mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

existingCodes := []BackupCode{{Hash: "existinghash"}}
existingMetadata := buildMetadataJSON(t, existingCodes, 1)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{
BackupCodes: existingMetadata,
}, nil).Once()

mockDB.On("UpdateUserBackupCodes", mock.Anything, mock.MatchedBy(func(arg models.UpdateUserBackupCodesParams) bool {
return arg.ID == userID && arg.LastUpdatedBy.String == updatedBy
})).Return(nil).Once()

newCodes := []BackupCode{{Hash: "newhash1"}, {Hash: "newhash2"}}
err := gen.UpdateBackupCodes(ctx, userID, newCodes, updatedBy)

require.NoError(t, err)
mockDB.AssertExpectations(t)
})

t.Run("preserves generated_at from existing metadata", func(t *testing.T) {
mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

existingCodes := []BackupCode{{Hash: "hash"}}
existingMetadata := buildMetadataJSON(t, existingCodes, 1)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{
BackupCodes: existingMetadata,
}, nil).Once()

mockDB.On("UpdateUserBackupCodes", mock.Anything, mock.MatchedBy(func(arg models.UpdateUserBackupCodesParams) bool {
var metadata Metadata
if err := json.Unmarshal(arg.BackupCodes, &metadata); err != nil {
return false
}
return metadata.GeneratedAt == "2025-06-22T10:30:00Z"
})).Return(nil).Once()

err := gen.UpdateBackupCodes(ctx, userID, []BackupCode{{Hash: "newhash"}}, updatedBy)

require.NoError(t, err)
mockDB.AssertExpectations(t)
})
}

func TestUpdateBackupCodes_DBError(t *testing.T) {
ctx := context.Background()
userID := int32(42)
updatedBy := "admin"

t.Run("error on get current metadata", func(t *testing.T) {
mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{}, assert.AnError).Once()

err := gen.UpdateBackupCodes(ctx, userID, []BackupCode{{Hash: "h"}}, updatedBy)

require.Error(t, err)
assert.Contains(t, err.Error(), "failed to get current backup codes metadata")
mockDB.AssertExpectations(t)
})

t.Run("error on update", func(t *testing.T) {
mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

existingMetadata := buildMetadataJSON(t, []BackupCode{{Hash: "h"}}, 1)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{
BackupCodes: existingMetadata,
}, nil).Once()
mockDB.On("UpdateUserBackupCodes", mock.Anything, mock.Anything).Return(assert.AnError).Once()

err := gen.UpdateBackupCodes(ctx, userID, []BackupCode{{Hash: "h"}}, updatedBy)

require.Error(t, err)
assert.Contains(t, err.Error(), "failed to update backup codes")
mockDB.AssertExpectations(t)
})
}

func TestConsumeBackupCode(t *testing.T) {
ctx := context.Background()
userID := int32(42)
updatedBy := "admin"
plainCode := "abcde-12345"

// Use low-cost bcrypt for test speed
hasher := password.NewBcryptHasher(&password.BcryptConfig{Cost: 4})
hash, err := hasher.GenerateHash(plainCode)
require.NoError(t, err)

t.Run("successful consumption", func(t *testing.T) {
mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

storedCodes := []BackupCode{
{Hash: hash},
{Hash: "$2a$04$otherhashvaluexxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},
}
metadataJSON := buildMetadataJSON(t, storedCodes, 2)

// First call: GetBackupCodes reads from DB
mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{
BackupCodes: metadataJSON,
}, nil).Once()

// Second call: UpdateBackupCodes reads current metadata
mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{
BackupCodes: metadataJSON,
}, nil).Once()

// UpdateBackupCodes writes the reduced set
mockDB.On("UpdateUserBackupCodes", mock.Anything, mock.MatchedBy(func(arg models.UpdateUserBackupCodesParams) bool {
var metadata Metadata
if err := json.Unmarshal(arg.BackupCodes, &metadata); err != nil {
return false
}
return metadata.CodesRemaining == 1
})).Return(nil).Once()

consumed, err := gen.ConsumeBackupCode(ctx, userID, plainCode, updatedBy)

require.NoError(t, err)
assert.True(t, consumed)
mockDB.AssertExpectations(t)
})
}

func TestConsumeBackupCode_Invalid(t *testing.T) {
ctx := context.Background()
userID := int32(42)
updatedBy := "admin"

mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

// Use low-cost bcrypt hash for a different code
hasher := password.NewBcryptHasher(&password.BcryptConfig{Cost: 4})
hash, err := hasher.GenerateHash("zzzzz-99999")
require.NoError(t, err)

storedCodes := []BackupCode{{Hash: hash}}
metadataJSON := buildMetadataJSON(t, storedCodes, 1)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{
BackupCodes: metadataJSON,
}, nil).Once()

consumed, err := gen.ConsumeBackupCode(ctx, userID, "wrong-codes", updatedBy)

require.NoError(t, err)
assert.False(t, consumed)
mockDB.AssertExpectations(t)
}

func TestConsumeBackupCode_AlreadyUsed(t *testing.T) {
ctx := context.Background()
userID := int32(42)
updatedBy := "admin"

mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

// No codes remaining — simulates all codes already consumed
metadataJSON := buildMetadataJSON(t, []BackupCode{}, 0)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{
BackupCodes: metadataJSON,
}, nil).Once()

consumed, err := gen.ConsumeBackupCode(ctx, userID, "abcde-12345", updatedBy)

require.NoError(t, err)
assert.False(t, consumed)
mockDB.AssertExpectations(t)
}

func TestConsumeBackupCode_DBError(t *testing.T) {
ctx := context.Background()
userID := int32(42)
updatedBy := "admin"

mockDB := mocks.NewServiceInterface(t)
gen := NewBackupCodeGenerator(mockDB)

mockDB.On("GetUserBackupCodes", mock.Anything, userID).Return(models.GetUserBackupCodesRow{}, assert.AnError).Once()

consumed, err := gen.ConsumeBackupCode(ctx, userID, "abcde-12345", updatedBy)

require.Error(t, err)
assert.False(t, consumed)
assert.Contains(t, err.Error(), "failed to get backup codes")
mockDB.AssertExpectations(t)
}
Loading