Skip to content

Commit 4b1eae9

Browse files
feat(auth): structural refactor
1 parent a2fbe39 commit 4b1eae9

7 files changed

Lines changed: 498 additions & 5 deletions

File tree

‎internal/auth/adapters/persistence.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,7 @@ func (r *UserRepoAdapter) CreateUser(ctx context.Context, arg database.CreateUse
3030
func (r *UserRepoAdapter) GetUserByID(ctx context.Context, uid uuid.UUID) (database.User, error) {
3131
return r.queries.GetUserByID(ctx, uid)
3232
}
33+
34+
func (r *UserRepoAdapter) CreateBotToken(ctx context.Context, arg database.CreateBotTokenParams) (database.BotToken, error) {
35+
return r.queries.CreateBotToken(ctx, arg)
36+
}

‎internal/auth/ports/interfaces.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ type UserRepo interface {
2222
GetUserByID(ctx context.Context, uid uuid.UUID) (database.User, error)
2323
}
2424

25+
// BotRepo defines the interface for bot token persistence
26+
type BotRepo interface {
27+
CreateBotToken(ctx context.Context, arg database.CreateBotTokenParams) (database.BotToken, error)
28+
}
29+
2530
// TokenProvider defines the interface for token generation
2631
type TokenProvider interface {
2732
GenerateToken(user database.User) (string, error)

‎internal/auth/service/service.go‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,131 @@
11
package service
22

33
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/hex"
7+
"errors"
8+
"time"
9+
410
"github.com/capyrpi/api/internal/auth/ports"
11+
"github.com/capyrpi/api/internal/database"
12+
"github.com/google/uuid"
13+
"github.com/jackc/pgx/v5"
14+
"github.com/jackc/pgx/v5/pgtype"
15+
"golang.org/x/crypto/bcrypt"
516
)
617

18+
type AuthResult struct {
19+
User database.User
20+
Token string
21+
}
22+
23+
type BotTokenResult struct {
24+
Token database.BotToken
25+
RawToken string
26+
}
27+
728
type AuthService struct {
829
userRepo ports.UserRepo
30+
botRepo ports.BotRepo
931
tokenProvider ports.TokenProvider
1032
googleAuth ports.OAuthProvider
1133
microsoftAuth ports.OAuthProvider
1234
}
1335

1436
func NewAuthService(
1537
userRepo ports.UserRepo,
38+
botRepo ports.BotRepo,
1639
tokenProvider ports.TokenProvider,
1740
googleAuth ports.OAuthProvider,
1841
microsoftAuth ports.OAuthProvider,
1942
) *AuthService {
2043
return &AuthService{
2144
userRepo: userRepo,
45+
botRepo: botRepo,
2246
tokenProvider: tokenProvider,
2347
googleAuth: googleAuth,
2448
microsoftAuth: microsoftAuth,
2549
}
2650
}
51+
52+
func (s *AuthService) HandleOAuthCallback(ctx context.Context, providerName string, code string) (*AuthResult, error) {
53+
var provider ports.OAuthProvider
54+
switch providerName {
55+
case "google":
56+
provider = s.googleAuth
57+
case "microsoft":
58+
provider = s.microsoftAuth
59+
default:
60+
return nil, errors.New("invalid provider")
61+
}
62+
63+
userInfo, err := provider.ExchangeCode(ctx, code)
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
pgEmail := pgtype.Text{String: userInfo.Email, Valid: true}
69+
user, err := s.userRepo.GetUserByEmail(ctx, pgEmail)
70+
if err != nil {
71+
if err != pgx.ErrNoRows {
72+
return nil, err
73+
}
74+
75+
// Create user if not exists
76+
user, err = s.userRepo.CreateUser(ctx, database.CreateUserParams{
77+
FirstName: userInfo.FirstName,
78+
LastName: userInfo.LastName,
79+
PersonalEmail: pgEmail,
80+
SchoolEmail: pgtype.Text{Valid: false},
81+
Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true},
82+
})
83+
if err != nil {
84+
return nil, err
85+
}
86+
}
87+
88+
token, err := s.tokenProvider.GenerateToken(user)
89+
if err != nil {
90+
return nil, err
91+
}
92+
93+
return &AuthResult{User: user, Token: token}, nil
94+
}
95+
96+
func (s *AuthService) GenerateBotToken(ctx context.Context, name string, createdBy uuid.UUID, expiresAt *time.Time) (*BotTokenResult, error) {
97+
rawToken, err := generateSecureToken(32)
98+
if err != nil {
99+
return nil, err
100+
}
101+
102+
hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), bcrypt.DefaultCost)
103+
if err != nil {
104+
return nil, err
105+
}
106+
107+
pgExpiresAt := pgtype.Timestamp{Valid: false}
108+
if expiresAt != nil {
109+
pgExpiresAt = pgtype.Timestamp{Time: *expiresAt, Valid: true}
110+
}
111+
112+
token, err := s.botRepo.CreateBotToken(ctx, database.CreateBotTokenParams{
113+
TokenHash: string(hashedToken),
114+
Name: name,
115+
CreatedBy: createdBy,
116+
ExpiresAt: pgExpiresAt,
117+
})
118+
if err != nil {
119+
return nil, err
120+
}
121+
122+
return &BotTokenResult{Token: token, RawToken: rawToken}, nil
123+
}
124+
125+
func generateSecureToken(length int) (string, error) {
126+
bytes := make([]byte, length)
127+
if _, err := rand.Read(bytes); err != nil {
128+
return "", err
129+
}
130+
return hex.EncodeToString(bytes), nil
131+
}
Lines changed: 170 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,180 @@
11
package service_test
22

33
import (
4+
"context"
5+
"errors"
46
"testing"
7+
"time"
58

9+
"github.com/capyrpi/api/internal/auth/ports"
610
"github.com/capyrpi/api/internal/auth/service"
11+
"github.com/capyrpi/api/internal/database"
12+
"github.com/google/uuid"
13+
"github.com/jackc/pgx/v5"
14+
"github.com/jackc/pgx/v5/pgtype"
715
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/mock"
817
)
918

10-
func TestAuthService(t *testing.T) {
11-
// Just a dummy test to ensure the package imports are correct
12-
// and the struct can be instantiated (with nil dependencies for now)
13-
svc := service.NewAuthService(nil, nil, nil, nil)
14-
assert.NotNil(t, svc)
19+
// Mocks
20+
type MockUserRepo struct {
21+
mock.Mock
22+
}
23+
24+
func (m *MockUserRepo) GetUserByEmail(ctx context.Context, email pgtype.Text) (database.User, error) {
25+
args := m.Called(ctx, email)
26+
return args.Get(0).(database.User), args.Error(1)
27+
}
28+
29+
func (m *MockUserRepo) CreateUser(ctx context.Context, arg database.CreateUserParams) (database.User, error) {
30+
args := m.Called(ctx, arg)
31+
return args.Get(0).(database.User), args.Error(1)
32+
}
33+
34+
func (m *MockUserRepo) GetUserByID(ctx context.Context, uid uuid.UUID) (database.User, error) {
35+
args := m.Called(ctx, uid)
36+
return args.Get(0).(database.User), args.Error(1)
37+
}
38+
39+
type MockBotRepo struct {
40+
mock.Mock
41+
}
42+
43+
func (m *MockBotRepo) CreateBotToken(ctx context.Context, arg database.CreateBotTokenParams) (database.BotToken, error) {
44+
args := m.Called(ctx, arg)
45+
return args.Get(0).(database.BotToken), args.Error(1)
46+
}
47+
48+
type MockTokenProvider struct {
49+
mock.Mock
50+
}
51+
52+
func (m *MockTokenProvider) GenerateToken(user database.User) (string, error) {
53+
args := m.Called(user)
54+
return args.String(0), args.Error(1)
55+
}
56+
57+
type MockOAuthProvider struct {
58+
mock.Mock
59+
}
60+
61+
func (m *MockOAuthProvider) GetAuthURL(state string) string {
62+
args := m.Called(state)
63+
return args.String(0)
64+
}
65+
66+
func (m *MockOAuthProvider) ExchangeCode(ctx context.Context, code string) (*ports.UserInfo, error) {
67+
args := m.Called(ctx, code)
68+
if args.Get(0) == nil {
69+
return nil, args.Error(1)
70+
}
71+
return args.Get(0).(*ports.UserInfo), args.Error(1)
72+
}
73+
74+
func TestAuthService_HandleOAuthCallback(t *testing.T) {
75+
// Setup
76+
mockUserRepo := new(MockUserRepo)
77+
mockBotRepo := new(MockBotRepo)
78+
mockTokenProvider := new(MockTokenProvider)
79+
mockGoogle := new(MockOAuthProvider)
80+
mockMicrosoft := new(MockOAuthProvider)
81+
82+
svc := service.NewAuthService(mockUserRepo, mockBotRepo, mockTokenProvider, mockGoogle, mockMicrosoft)
83+
ctx := context.Background()
84+
85+
t.Run("Success existing user", func(t *testing.T) {
86+
email := "test@example.com"
87+
userInfo := &ports.UserInfo{Email: email, FirstName: "John", LastName: "Doe"}
88+
user := database.User{
89+
Uid: uuid.New(),
90+
FirstName: "John",
91+
LastName: "Doe",
92+
PersonalEmail: pgtype.Text{String: email, Valid: true},
93+
}
94+
token := "jwt-token"
95+
96+
mockGoogle.On("ExchangeCode", ctx, "auth-code").Return(userInfo, nil)
97+
mockUserRepo.On("GetUserByEmail", ctx, pgtype.Text{String: email, Valid: true}).Return(user, nil)
98+
mockTokenProvider.On("GenerateToken", user).Return(token, nil)
99+
100+
res, err := svc.HandleOAuthCallback(ctx, "google", "auth-code")
101+
102+
assert.NoError(t, err)
103+
assert.Equal(t, user, res.User)
104+
assert.Equal(t, token, res.Token)
105+
mockGoogle.AssertExpectations(t)
106+
mockUserRepo.AssertExpectations(t)
107+
mockTokenProvider.AssertExpectations(t)
108+
})
109+
110+
t.Run("Success new user", func(t *testing.T) {
111+
email := "new@example.com"
112+
userInfo := &ports.UserInfo{Email: email, FirstName: "Jane", LastName: "Doe"}
113+
user := database.User{
114+
Uid: uuid.New(),
115+
FirstName: "Jane",
116+
LastName: "Doe",
117+
PersonalEmail: pgtype.Text{String: email, Valid: true},
118+
}
119+
token := "jwt-token"
120+
121+
mockGoogle.On("ExchangeCode", ctx, "new-code").Return(userInfo, nil)
122+
mockUserRepo.On("GetUserByEmail", ctx, pgtype.Text{String: email, Valid: true}).Return(database.User{}, pgx.ErrNoRows)
123+
mockUserRepo.On("CreateUser", ctx, mock.AnythingOfType("database.CreateUserParams")).Return(user, nil)
124+
mockTokenProvider.On("GenerateToken", user).Return(token, nil)
125+
126+
res, err := svc.HandleOAuthCallback(ctx, "google", "new-code")
127+
128+
assert.NoError(t, err)
129+
assert.Equal(t, user, res.User)
130+
assert.Equal(t, token, res.Token)
131+
})
132+
133+
t.Run("Invalid provider", func(t *testing.T) {
134+
res, err := svc.HandleOAuthCallback(ctx, "invalid", "code")
135+
assert.Error(t, err)
136+
assert.Nil(t, res)
137+
})
138+
139+
t.Run("Exchange code error", func(t *testing.T) {
140+
mockGoogle.On("ExchangeCode", ctx, "bad-code").Return(nil, errors.New("exchange error"))
141+
res, err := svc.HandleOAuthCallback(ctx, "google", "bad-code")
142+
assert.Error(t, err)
143+
assert.Nil(t, res)
144+
})
145+
}
146+
147+
func TestAuthService_GenerateBotToken(t *testing.T) {
148+
// Setup
149+
mockUserRepo := new(MockUserRepo)
150+
mockBotRepo := new(MockBotRepo)
151+
mockTokenProvider := new(MockTokenProvider)
152+
mockGoogle := new(MockOAuthProvider)
153+
mockMicrosoft := new(MockOAuthProvider)
154+
155+
svc := service.NewAuthService(mockUserRepo, mockBotRepo, mockTokenProvider, mockGoogle, mockMicrosoft)
156+
ctx := context.Background()
157+
uid := uuid.New()
158+
159+
t.Run("Success", func(t *testing.T) {
160+
name := "My Bot"
161+
botToken := database.BotToken{
162+
TokenID: uuid.New(),
163+
Name: name,
164+
CreatedBy: uid,
165+
CreatedAt: pgtype.Timestamp{Time: time.Now(), Valid: true},
166+
}
167+
168+
// Use mock.MatchedBy to check arguments
169+
mockBotRepo.On("CreateBotToken", ctx, mock.MatchedBy(func(arg database.CreateBotTokenParams) bool {
170+
return arg.Name == name && arg.CreatedBy == uid && len(arg.TokenHash) > 0
171+
})).Return(botToken, nil)
172+
173+
res, err := svc.GenerateBotToken(ctx, name, uid, nil)
174+
175+
assert.NoError(t, err)
176+
assert.Equal(t, botToken, res.Token)
177+
assert.NotEmpty(t, res.RawToken)
178+
mockBotRepo.AssertExpectations(t)
179+
})
15180
}

0 commit comments

Comments
 (0)