test: add tests (unit/api) for additional api keys

This commit is contained in:
monomarh
2025-11-16 21:34:35 +01:00
parent fbfd94d6e5
commit ad3d12d38d
27 changed files with 584 additions and 40 deletions

View File

@@ -122,7 +122,6 @@ func main() {
os.Exit(0)
}
config = conf.Load(*configFlag, version)
slog.Info("loaded configuration", "configFile", *configFlag)
// Configure Swagger docs
docs.SwaggerInfo.BasePath = config.Server.BasePath + "/api"

View File

@@ -34,16 +34,16 @@ type AuthenticateMiddleware struct {
optionalForMethods []string
redirectTarget string // optional
redirectErrorMessage string // optional
onlyRWApiKey bool
requireFullAccessKey bool // true only for heartbeat routes
}
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
return &AuthenticateMiddleware{
config: conf.Get(),
userSrvc: userService,
optionalForPaths: []string{},
optionalForMethods: []string{},
onlyRWApiKey: false,
config: conf.Get(),
userSrvc: userService,
optionalForPaths: []string{},
optionalForMethods: []string{},
requireFullAccessKey: false,
}
}
@@ -67,8 +67,8 @@ func (m *AuthenticateMiddleware) WithRedirectErrorMessage(message string) *Authe
return m
}
func (m *AuthenticateMiddleware) WithOnlyRWApiKey(onlyRW bool) *AuthenticateMiddleware {
m.onlyRWApiKey = onlyRW
func (m *AuthenticateMiddleware) WithFullAccessOnly(readOnly bool) *AuthenticateMiddleware {
m.requireFullAccessKey = readOnly
return m
}
@@ -145,7 +145,7 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKeyHeader(r *http.Request) (*mod
var user *models.User
userKey := strings.TrimSpace(key)
user, err = m.userSrvc.GetUserByKey(userKey, m.onlyRWApiKey)
user, err = m.userSrvc.GetUserByKey(userKey, m.requireFullAccessKey)
if err != nil {
return nil, err
}
@@ -159,7 +159,7 @@ func (m *AuthenticateMiddleware) tryGetUserByApiKeyQuery(r *http.Request) (*mode
if userKey == "" {
return nil, errEmptyKey
}
user, err := m.userSrvc.GetUserByKey(userKey, m.onlyRWApiKey)
user, err := m.userSrvc.GetUserByKey(userKey, m.requireFullAccessKey)
if err != nil {
return nil, err
}

View File

@@ -23,9 +23,10 @@ import (
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Success(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
readOnlyApiKey := false
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
testUser := &models.User{ApiKey: testApiKey}
// In the case of the API Key from User Model - it's always full access
testApiKeyRequireFullAccess := false
mockRequest := &http.Request{
Header: http.Header{
@@ -34,7 +35,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Success(t *testing.T) {
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey, readOnlyApiKey).Return(testUser, nil)
userServiceMock.On("GetUserByKey", testApiKey, testApiKeyRequireFullAccess).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock)
@@ -58,6 +59,29 @@ func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Invalid(t *testing.T) {
userServiceMock := new(mocks.UserServiceMock)
sut := NewAuthenticateMiddleware(userServiceMock)
sut.WithFullAccessOnly(false)
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
assert.Error(t, err)
assert.Nil(t, result)
}
func TestAuthenticateMiddleware_tryGetUserByApiKeyHeaderWithReadOnlyKey_Invalid(t *testing.T) {
testApiKey := "read-only-additional-key"
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
mockRequest := &http.Request{
Header: http.Header{
"Authorization": []string{fmt.Sprintf("Basic %s", testToken)},
},
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey, true).Return(nil, errors.New("forbidden: requires full access"))
sut := NewAuthenticateMiddleware(userServiceMock)
sut.WithFullAccessOnly(true)
result, err := sut.tryGetUserByApiKeyHeader(mockRequest)
@@ -67,8 +91,9 @@ func TestAuthenticateMiddleware_tryGetUserByApiKeyHeader_Invalid(t *testing.T) {
func TestAuthenticateMiddleware_tryGetUserByApiKeyQuery_Success(t *testing.T) {
testApiKey := "z5uig69cn9ut93n"
readOnlyApiKey := false
testUser := &models.User{ApiKey: testApiKey}
// In the case of the API Key from User Model - it's always full access
testApiKeyRequireFullAccess := true
params := url.Values{}
params.Add("api_key", testApiKey)
@@ -79,9 +104,10 @@ func TestAuthenticateMiddleware_tryGetUserByApiKeyQuery_Success(t *testing.T) {
}
userServiceMock := new(mocks.UserServiceMock)
userServiceMock.On("GetUserByKey", testApiKey, readOnlyApiKey).Return(testUser, nil)
userServiceMock.On("GetUserByKey", testApiKey, testApiKeyRequireFullAccess).Return(testUser, nil)
sut := NewAuthenticateMiddleware(userServiceMock)
sut.WithFullAccessOnly(true)
result, err := sut.tryGetUserByApiKeyQuery(mockRequest)

39
mocks/api_key_service.go Normal file
View File

@@ -0,0 +1,39 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type MockApiKeyService struct {
mock.Mock
}
func (m *MockApiKeyService) GetByApiKey(apiKey string, requireFullAccessKey bool) (*models.ApiKey, error) {
args := m.Called(apiKey, requireFullAccessKey)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.ApiKey), args.Error(1)
}
func (m *MockApiKeyService) GetByUser(userID string) ([]*models.ApiKey, error) {
args := m.Called(userID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.ApiKey), args.Error(1)
}
func (m *MockApiKeyService) Create(apiKey *models.ApiKey) (*models.ApiKey, error) {
args := m.Called(apiKey)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.ApiKey), args.Error(1)
}
func (m *MockApiKeyService) Delete(apiKey *models.ApiKey) error {
args := m.Called(apiKey)
return args.Error(0)
}

37
mocks/mail_service.go Normal file
View File

@@ -0,0 +1,37 @@
package mocks
import (
"time"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type MailServiceMock struct {
mock.Mock
}
func (m *MailServiceMock) SendPasswordReset(user *models.User, resetLink string) error {
args := m.Called(user, resetLink)
return args.Error(0)
}
func (m *MailServiceMock) SendWakatimeFailureNotification(user *models.User, numFailures int) error {
args := m.Called(user, numFailures)
return args.Error(0)
}
func (m *MailServiceMock) SendImportNotification(user *models.User, duration time.Duration, numHeartbeats int) error {
args := m.Called(user, duration, numHeartbeats)
return args.Error(0)
}
func (m *MailServiceMock) SendReport(user *models.User, report *models.Report) error {
args := m.Called(user, report)
return args.Error(0)
}
func (m *MailServiceMock) SendSubscriptionNotification(user *models.User, hasExpired bool) error {
args := m.Called(user, hasExpired)
return args.Error(0)
}

125
mocks/user_repository.go Normal file
View File

@@ -0,0 +1,125 @@
package mocks
import (
"time"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
type UserRepositoryMock struct {
BaseRepositoryMock
mock.Mock
}
func (m *UserRepositoryMock) FindOne(user models.User) (*models.User, error) {
args := m.Called(user)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetByIds(userIds []string) ([]*models.User, error) {
args := m.Called(userIds)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetAll() ([]*models.User, error) {
args := m.Called()
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetMany(ids []string) ([]*models.User, error) {
args := m.Called(ids)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
args := m.Called(reportsEnabled)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetAllByLeaderboard(leaderboardEnabled bool) ([]*models.User, error) {
args := m.Called(leaderboardEnabled)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetByLoggedInBefore(t time.Time) ([]*models.User, error) {
args := m.Called(t)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
args := m.Called(t)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) GetByLastActiveAfter(t time.Time) ([]*models.User, error) {
args := m.Called(t)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserRepositoryMock) Count() (int64, error) {
args := m.Called()
return args.Get(0).(int64), args.Error(1)
}
func (m *UserRepositoryMock) InsertOrGet(user *models.User) (*models.User, bool, error) {
args := m.Called(user)
if args.Get(0) == nil {
return nil, args.Bool(1), args.Error(2)
}
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
}
func (m *UserRepositoryMock) Update(user *models.User) (*models.User, error) {
args := m.Called(user)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserRepositoryMock) UpdateField(user *models.User, key string, value interface{}) (*models.User, error) {
args := m.Called(user, key, value)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.User), args.Error(1)
}
func (m *UserRepositoryMock) Delete(user *models.User) error {
args := m.Called(user)
return args.Error(0)
}
func (m *UserRepositoryMock) DeleteTx(user *models.User, tx *gorm.DB) error {
args := m.Called(user, tx)
return args.Error(0)
}

View File

@@ -26,10 +26,16 @@ func (r *ApiKeyRepository) GetAll() ([]*models.ApiKey, error) {
return keys, nil
}
func (r *ApiKeyRepository) GetByApiKey(apiKey string, readOnly bool) (*models.ApiKey, error) {
func (r *ApiKeyRepository) GetByApiKey(apiKey string, requireFullAccessKey bool) (*models.ApiKey, error) {
key := &models.ApiKey{}
if err := r.db.Where(&models.ApiKey{ApiKey: apiKey, ReadOnly: readOnly}).First(key).Error; err != nil {
return key, err
query := r.db.Preload("User").Where("api_key = ?", apiKey)
if requireFullAccessKey {
query = query.Where("read_only = ?", false)
}
if err := query.First(key).Error; err != nil {
return nil, err
}
return key, nil
}

View File

@@ -37,7 +37,7 @@ func NewHeartbeatApiHandler(userService services.IUserService, heartbeatService
func (h *HeartbeatApiHandler) RegisterRoutes(router chi.Router) {
router.Group(func(r chi.Router) {
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithOptionalForMethods(http.MethodOptions).WithOnlyRWApiKey(true).Handler,
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithOptionalForMethods(http.MethodOptions).WithFullAccessOnly(true).Handler,
customMiddleware.NewWakatimeRelayMiddleware().Handler,
)
// see https://github.com/muety/wakapi/issues/203

View File

@@ -13,7 +13,6 @@ import (
"github.com/duke-git/lancet/v2/slice"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"

View File

@@ -22,11 +22,11 @@ require (
github.com/dchest/captcha v1.1.0 // indirect
github.com/duke-git/lancet/v2 v2.3.7 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/getsentry/sentry-go v0.35.3 // indirect
github.com/getsentry/sentry-go v0.36.2 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/gofrs/uuid/v5 v5.3.2 // indirect
github.com/gohugoio/hashstructure v0.5.0 // indirect
github.com/gofrs/uuid/v5 v5.4.0 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -44,24 +44,24 @@ require (
github.com/mileusna/useragent v1.3.5 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/samber/lo v1.51.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/samber/slog-common v0.19.0 // indirect
github.com/samber/slog-multi v1.5.0 // indirect
github.com/samber/slog-sentry/v2 v2.9.3 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/sqlserver v1.6.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.0 // indirect
modernc.org/sqlite v1.39.1 // indirect
)

View File

@@ -48,6 +48,7 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/getsentry/sentry-go v0.35.1 h1:iopow6UVLE2aXu46xKVIs8Z9D/YZkJrHkgozrxa+tOQ=
github.com/getsentry/sentry-go v0.35.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
github.com/getsentry/sentry-go v0.35.3/go.mod h1:mdL49ixwT2yi57k5eh7mpnDyPybixPzlzEJFu0Z76QA=
github.com/getsentry/sentry-go v0.36.2/go.mod h1:p5Im24mJBeruET8Q4bbcMfCQ+F+Iadc4L48tB1apo2c=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -58,8 +59,10 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=
github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
@@ -134,6 +137,7 @@ github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf h1:zd7IU9rxVMl2
github.com/muety/artifex/v2 v2.0.1-0.20221201142708-74e7d3f6feaf/go.mod h1:eElbcdMwTDc7Wzl7A46IopgkC6a9nV7jOB6Mw8r0waE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
@@ -155,6 +159,7 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI=
github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M=
github.com/samber/slog-multi v1.4.1 h1:OVBxOKcorBcGQVKjwlraA41JKWwHQyB/3KfzL3IJAYg=
@@ -197,9 +202,11 @@ golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5D
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -260,6 +267,7 @@ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -279,6 +287,7 @@ golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -295,6 +304,7 @@ golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -348,6 +358,7 @@ modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJ
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -45,8 +45,8 @@ func NewApiKeyService(apiKeyRepository repositories.IApiKeyRepository) *ApiKeySe
return srv
}
func (srv *ApiKeyService) GetByApiKey(apiKey string, readOnly bool) (*models.ApiKey, error) {
return srv.repository.GetByApiKey(apiKey, readOnly)
func (srv *ApiKeyService) GetByApiKey(apiKey string, requireFullAccessKey bool) (*models.ApiKey, error) {
return srv.repository.GetByApiKey(apiKey, requireFullAccessKey)
}
func (srv *ApiKeyService) GetByUser(userId string) ([]*models.ApiKey, error) {

View File

@@ -99,7 +99,7 @@ func (srv *UserService) GetUserById(userId string) (*models.User, error) {
return u, nil
}
func (srv *UserService) GetUserByKey(key string, onlyRWApiKey bool) (*models.User, error) {
func (srv *UserService) GetUserByKey(key string, requireFullAccessKey bool) (*models.User, error) {
if key == "" {
return nil, errors.New("key must not be empty")
}
@@ -114,7 +114,7 @@ func (srv *UserService) GetUserByKey(key string, onlyRWApiKey bool) (*models.Use
return u, nil
}
apiKey, err := srv.apiKeyService.GetByApiKey(key, !onlyRWApiKey)
apiKey, err := srv.apiKeyService.GetByApiKey(key, requireFullAccessKey)
if err == nil {
srv.cache.SetDefault(apiKey.User.ID, apiKey.User)
return apiKey.User, nil

103
services/user_test.go Normal file
View File

@@ -0,0 +1,103 @@
package services
import (
"errors"
"testing"
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/suite"
)
const (
TestUserID = "muety"
TestAPIKey = "full-access-key-from-user-model"
)
type UserServiceTestSuite struct {
suite.Suite
TestUser *models.User
KeyValueService *mocks.KeyValueServiceMock
MailService *mocks.MailServiceMock
ApiKeyService *mocks.MockApiKeyService
UserRepo *mocks.UserRepositoryMock
}
func (suite *UserServiceTestSuite) SetupSuite() {
suite.TestUser = &models.User{ID: TestUserID, ApiKey: TestAPIKey}
}
func (suite *UserServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.KeyValueService = new(mocks.KeyValueServiceMock)
suite.MailService = new(mocks.MailServiceMock)
suite.ApiKeyService = new(mocks.MockApiKeyService)
suite.UserRepo = new(mocks.UserRepositoryMock)
}
func TestUserServiceTestSuite(t *testing.T) {
suite.Run(t, new(UserServiceTestSuite))
}
func (suite *UserServiceTestSuite) TestUserService_GetByEmptyKey_Failed() {
sut := NewUserService(suite.KeyValueService, suite.MailService, suite.ApiKeyService, suite.UserRepo)
result, err := sut.GetUserByKey("", false)
suite.Nil(result)
suite.NotNil(err)
suite.Equal(err, errors.New("key must not be empty"))
}
func (suite *UserServiceTestSuite) TestUserService_GetByKeyFromCache_Success() {
userCached := &models.User{ID: TestUserID, ApiKey: "cached-key"}
userCache := cache.New(cache.NoExpiration, cache.NoExpiration)
userCache.SetDefault(TestAPIKey, userCached)
sut := &UserService{cache: userCache}
result, err := sut.GetUserByKey(TestAPIKey, false)
suite.Nil(err)
suite.NotNil(result)
suite.Equal(1, userCache.ItemCount())
suite.Equal(userCached, result)
suite.Equal(userCached.ApiKey, result.ApiKey)
}
func (suite *UserServiceTestSuite) TestUserService_GetByKeyFromUserModel_Success() {
sut := NewUserService(suite.KeyValueService, suite.MailService, suite.ApiKeyService, suite.UserRepo)
suite.UserRepo.On("FindOne", models.User{ApiKey: TestAPIKey}).Return(suite.TestUser, nil)
result, err := sut.GetUserByKey(TestAPIKey, false)
suite.Nil(err)
suite.NotNil(result)
suite.Equal(suite.TestUser, result)
suite.Equal(suite.TestUser.ApiKey, result.ApiKey)
}
func (suite *UserServiceTestSuite) TestUserService_GetByKeyFromAdditionalApiKeys_Success() {
sut := NewUserService(suite.KeyValueService, suite.MailService, suite.ApiKeyService, suite.UserRepo)
suite.UserRepo.On("FindOne", models.User{ApiKey: TestAPIKey}).Return(nil, errors.New("not found"))
suite.ApiKeyService.On("GetByApiKey", TestAPIKey, true).Return(&models.ApiKey{User: suite.TestUser}, nil)
result, err := sut.GetUserByKey(TestAPIKey, true)
suite.Nil(err)
suite.NotNil(result)
suite.Equal(suite.TestUser, result)
suite.Equal(suite.TestUser.ApiKey, result.ApiKey)
}
func (suite *UserServiceTestSuite) TestUserService_GetByKeyFromAdditionalApiKeys_Failed() {
sut := NewUserService(suite.KeyValueService, suite.MailService, suite.ApiKeyService, suite.UserRepo)
suite.UserRepo.On("FindOne", models.User{ApiKey: TestAPIKey}).Return(nil, errors.New("not found"))
suite.ApiKeyService.On("GetByApiKey", TestAPIKey, true).Return(nil, errors.New("not found"))
result, err := sut.GetUserByKey(TestAPIKey, true)
suite.Nil(result)
suite.NotNil(err)
suite.Equal(err, errors.New("not found"))
}

View File

@@ -24,4 +24,9 @@ INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_
VALUES ('writeuser', 'f7aa255c-8647-4d0b-b90f-621c58fd580f', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:56',
'2021-05-28 14:35:05.118+02:00', 7, 0, 0, 1, 0, 0, 0, 1, '', '', 0);
INSERT INTO "api_keys" ("api_key", "user_id", "label", "read_only")
VALUES
('1c91f670-2309-45fb-9d7e-738c766e85a6', 'writeuser', 'Full Access Key', false),
('774f7e16-b9a3-433e-ac68-7e28a82a50ca', 'writeuser', 'Read Only Key', true);
COMMIT;

View File

@@ -26,6 +26,8 @@ DROP TABLE IF EXISTS "summary_items";
CREATE TABLE `summary_items` (`id` integer PRIMARY KEY AUTOINCREMENT,`summary_id` integer,`type` integer,`key` text,`total` integer,CONSTRAINT `fk_summaries_projects` FOREIGN KEY (`summary_id`) REFERENCES `summaries`(`id`) ON DELETE CASCADE ON UPDATE CASCADE);
DROP TABLE IF EXISTS "users";
CREATE TABLE `users` (`id` text,`api_key` text DEFAULT NULL,`email` text,`location` text,`password` text,`created_at` timestamp,`last_logged_in_at` timestamp,`share_data_max_days` integer,`share_editors` numeric DEFAULT false,`share_languages` numeric DEFAULT false,`share_projects` numeric DEFAULT false,`share_oss` numeric DEFAULT false,`share_machines` numeric DEFAULT false,`share_labels` numeric DEFAULT false,`share_activity_chart` numeric DEFAULT false,`is_admin` numeric DEFAULT false,`has_data` numeric DEFAULT false,`wakatime_api_key` text,`wakatime_api_url` text,`reset_token` text,`reports_weekly` numeric DEFAULT false,`public_leaderboard` numeric DEFAULT false,`subscribed_until` timestamp,`subscription_renewal` timestamp,`stripe_customer_id` text,`invited_by` text,`exclude_unknown_projects` numeric,`heartbeats_timeout_sec` integer DEFAULT 600,PRIMARY KEY (`id`),CONSTRAINT `uni_users_api_key` UNIQUE (`api_key`));
DROP TABLE IF EXISTS "api_keys";
CREATE TABLE `api_keys` (`api_key` text,`user_id` text NOT NULL,`label` varchar(64) NOT NULL,`read_only` boolean DEFAULT false,PRIMARY KEY (`api_key`));
DROP INDEX IF EXISTS "idx_alias_type_key";
CREATE INDEX `idx_alias_type_key` ON `aliases`(`type`,`key`);
DROP INDEX IF EXISTS "idx_alias_user";
@@ -68,4 +70,6 @@ DROP INDEX IF EXISTS "idx_user_email";
CREATE INDEX `idx_user_email` ON `users`(`email`);
DROP INDEX IF EXISTS "idx_user_project";
CREATE INDEX `idx_user_project` ON `heartbeats`(`user_id`,`project`);
DROP INDEX IF EXISTS "idx_api_key_user";
CREATE INDEX `idx_api_key_user` ON `api_keys`(`user_id`);
COMMIT;

View File

@@ -0,0 +1,23 @@
meta {
name: Authenticate (header, full access)
type: http
seq: 7
}
get {
url: {{BASE_URL}}/api/summary?interval=today
body: none
auth: bearer
}
params:query {
interval: today
}
auth:bearer {
token: {{ADDITIONAL_FULL_ACCESS_TOKEN}}
}
assert {
res.status: eq 200
}

View File

@@ -0,0 +1,23 @@
meta {
name: Authenticate (header, readonly)
type: http
seq: 8
}
get {
url: {{BASE_URL}}/api/summary?interval=today
body: none
auth: bearer
}
params:query {
interval: today
}
auth:bearer {
token: {{ADDITIONAL_READ_ONLY_TOKEN}}
}
assert {
res.status: eq 200
}

View File

@@ -0,0 +1,40 @@
meta {
name: Create heartbeats (alt 8, single, additional full access key)
type: http
seq: 9
}
post {
url: {{BASE_URL}}/api/users/current/heartbeats
body: json
auth: bearer
}
auth:bearer {
token: {{ADDITIONAL_FULL_ACCESS_TOKEN}}
}
body:json {
{
"entity": "/home/user1/dev/project1/main.go",
"project": "wakapi",
"language": "Go",
"is_write": true,
"type": "file",
"category": null,
"branch": null,
"time": {{ts1}}
}
}
assert {
res.status: eq 201
}
tests {
test("Response body is correct", function () {
expect(res.body.responses.length).to.eql(1);
expect(res.body.responses[0].length).to.eql(2);
expect(res.body.responses[0][1]).to.eql(201);
});
}

View File

@@ -1,7 +1,7 @@
meta {
name: Create heartbeats (get heartbeats test)
type: http
seq: 11
seq: 12
}
post {

View File

@@ -1,7 +1,7 @@
meta {
name: Create heartbeats (non-matching)
type: http
seq: 9
seq: 10
}
post {

View File

@@ -1,7 +1,7 @@
meta {
name: Create heartbeats (unauthorized)
type: http
seq: 10
seq: 11
}
post {
@@ -12,7 +12,7 @@ post {
body:json {
[{
"entity": "/home/user1/dev/proejct1/main.go",
"entity": "/home/user1/dev/project1/main.go",
"project": "wakapi",
"language": "Go",
"is_write": true,

View File

@@ -0,0 +1,32 @@
meta {
name: Create heartbeats (unauthorized, readonly key)
type: http
seq: 13
}
post {
url: {{BASE_URL}}/api/heartbeat
body: json
auth: bearer
}
auth:bearer {
token: {{ADDITIONAL_READ_ONLY_TOKEN}}
}
body:json {
[{
"entity": "/home/user1/dev/project1/main.go",
"project": "wakapi",
"language": "Go",
"is_write": true,
"type": "file",
"category": null,
"branch": null,
"time": {{tsNowMinus1Min}}
}]
}
assert {
res.status: eq 401
}

View File

@@ -0,0 +1,33 @@
meta {
name: Get heartbeats (full access key)
type: http
seq: 15
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date={{yesterdayDate}}
body: none
auth: bearer
}
params:query {
date: {{yesterdayDate}}
}
auth:bearer {
token: {{ADDITIONAL_FULL_ACCESS_TOKEN}}
}
assert {
res.status: eq 200
}
tests {
test("Response body is correct", function () {
const date = new Date(bru.getVar('yesterdayDateIso'))
expect(res.body.timezone).to.eql(bru.getCollectionVar('TZ'));
expect(new Date(res.body.start)).to.eql(date);
expect(new Date(res.body.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24 - 1000));
expect(res.body.data.length).to.eql(2);
});
}

View File

@@ -0,0 +1,33 @@
meta {
name: Get heartbeats (readonly key)
type: http
seq: 16
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date={{yesterdayDate}}
body: none
auth: bearer
}
params:query {
date: {{yesterdayDate}}
}
auth:bearer {
token: {{ADDITIONAL_READ_ONLY_TOKEN}}
}
assert {
res.status: eq 200
}
tests {
test("Response body is correct", function () {
const date = new Date(bru.getVar('yesterdayDateIso'))
expect(res.body.timezone).to.eql(bru.getCollectionVar('TZ'));
expect(new Date(res.body.start)).to.eql(date);
expect(new Date(res.body.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24 - 1000));
expect(res.body.data.length).to.eql(2);
});
}

View File

@@ -1,7 +1,7 @@
meta {
name: Get heartbeats
type: http
seq: 12
seq: 14
}
get {

View File

@@ -11,6 +11,8 @@ vars:pre-request {
BASE_URL: http://localhost:3000
READUSER_API_KEY: 33e7f538-0dce-4eba-8ffe-53db6814ed42
WRITEUSER_API_KEY: f7aa255c-8647-4d0b-b90f-621c58fd580f
ADDITIONAL_FULL_ACCESS_KEY: 1c91f670-2309-45fb-9d7e-738c766e85a6
ADDITIONAL_READ_ONLY_KEY: 774f7e16-b9a3-433e-ac68-7e28a82a50ca
TZ: Europe/Berlin
TZ_OFFSET: +02:00
READUSER_PASSWORD: testpw
@@ -45,6 +47,8 @@ script:pre-request {
// Auth stuff
const readApiKey = bru.getCollectionVar('READUSER_API_KEY')
const writeApiKey = bru.getCollectionVar('WRITEUSER_API_KEY')
const additionalFullAccessApiKey = bru.getCollectionVar('ADDITIONAL_FULL_ACCESS_KEY')
const additionalReadOnlyApiKey = bru.getCollectionVar('ADDITIONAL_READ_ONLY_KEY')
if (!readApiKey || !writeApiKey) {
throw new Error('no api key given')
@@ -52,6 +56,8 @@ script:pre-request {
bru.setVar('READUSER_TOKEN', base64encode(readApiKey))
bru.setVar('WRITEUSER_TOKEN', base64encode(writeApiKey))
bru.setVar('ADDITIONAL_FULL_ACCESS_TOKEN', base64encode(additionalFullAccessApiKey))
bru.setVar('ADDITIONAL_READ_ONLY_TOKEN', base64encode(additionalReadOnlyApiKey))
function base64encode(str) {
return Buffer.from(str, 'utf-8').toString('base64')