From 68e166d90f6e815e2b0bede6a1c0855428f10568 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Sun, 17 May 2026 22:06:36 +0200 Subject: [PATCH] Start testing tokens Signed-off-by: Nikolai Rodionov --- api/v1/tokens.go | 2 +- internal/controllers/tokens.go | 118 +++++++++++++++------------- internal/controllers/tokens_test.go | 60 ++++++++++---- internal/repository/accounts.go | 13 +++ internal/repository/tokens.go | 94 ++++++++++++++++++++++ 5 files changed, 218 insertions(+), 69 deletions(-) create mode 100644 internal/repository/tokens.go diff --git a/api/v1/tokens.go b/api/v1/tokens.go index 16887da..af4e417 100644 --- a/api/v1/tokens.go +++ b/api/v1/tokens.go @@ -55,7 +55,7 @@ func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateToken Scopes: permissions, } - token, err := srv.tokenCtrl.Create(ctx, tokenData) + token, _, err := srv.tokenCtrl.Create(ctx, tokenData) if err != nil { if errors.Is(err, controllers.ErrServerError) { return nil, status.Error(codes.Internal, "Something is broken on our side") diff --git a/internal/controllers/tokens.go b/internal/controllers/tokens.go index 5fdfb11..dbc0b79 100644 --- a/internal/controllers/tokens.go +++ b/internal/controllers/tokens.go @@ -14,6 +14,7 @@ import ( "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/logger" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/token" + "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository" "github.com/google/uuid" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" @@ -35,6 +36,7 @@ var ( ErrServerError = errors.New("internal server error") ErrUserTokenMismatch = errors.New("user doesn't match the token") ErrBadToken = errors.New("bad token") + ErrTokenNotFound = errors.New("token not found") ) type TokenData struct { @@ -96,15 +98,26 @@ func (ctrl *TokenController) VerifyTokenOwner(ctx context.Context, userID, token } // Create a new token, store its hash in the database and return the token value -func (ctrl *TokenController) Create(ctx context.Context, data *TokenData) (string, error) { +func (ctrl *TokenController) Create(ctx context.Context, data *TokenData) (string, string, error) { id := uuid.NewString() log := logger.FromContext(ctx).WithValues("uuid", id) log.V(2).Info("Creating a new token") + userExists, err := repository.IsAccountExist(ctx, ctrl.DB, data.UserID) + if err != nil { + log.Error(err, "Couldn't check whether a user exists") + return "", "", ErrServerError + } + + // If user doesn't exist, do not generate a token + if !userExists { + return "", "", ErrUserNotFound + } + tokenValue, err := token.GenerateToken() if err != nil { log.Error(err, "Couldn't create a token") - return "", ErrServerError + return "", "", ErrServerError } tokenHash := hashSHA256(tokenValue) @@ -112,29 +125,26 @@ func (ctrl *TokenController) Create(ctx context.Context, data *TokenData) (strin scopesJson, err := json.Marshal(data.Scopes) if err != nil { log.Error(err, "Couldn't marshal permissions into json") - return "", ErrServerError + return "", "", ErrServerError } - query := ` - INSERT INTO tokens (uuid, description, token_hash, user_id, scopes, created_at, generated_at, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` - - if _, err := ctrl.DB.Query( - query, - id, - data.Name, - tokenHash, - data.UserID, - scopesJson, - time.Now(), - time.Now(), - data.ExpiresAt, - ); err != nil { - log.Error(err, "Couldn't insert a token in the database") - return "", ErrServerError + queryData := &repository.TokenData{ + UUID: id, + Decsription: data.Name, + TokenHash: tokenHash, + UserID: data.UserID, + CreatedAt: time.Now(), + GeneratedAt: time.Now(), + ExpiresAt: data.ExpiresAt, + Scope: string(scopesJson), } - return tokenValue, nil + if err := repository.CreateToken(ctx, ctrl.DB, queryData); err != nil { + log.Error(err, "Couldn't create a token") + return "", "", ErrServerError + } + + return tokenValue, id, nil } // Update token name or permissions, other changes are ignored by this method @@ -148,9 +158,14 @@ func (ctrl *TokenController) Update(ctx context.Context, data *TokenData) error return ErrServerError } - query := "UPDATE tokens SET description = $1, scopes = $2 WHERE uuid = $3;" - if _, err := ctrl.DB.Query(query, data.Name, scopesJson, data.UUID); err != nil { - log.Error(err, "Couldn't update a token in the database") + queryData := &repository.TokenData{ + UUID: data.UUID, + Scope: string(scopesJson), + Decsription: data.Name, + } + + if err := repository.UpdateToken(ctx, ctrl.DB, queryData); err != nil { + log.Error(err, "Couldn't update a token") return ErrServerError } @@ -203,40 +218,36 @@ func (ctrl *TokenController) Regenerate(ctx context.Context, id string) (string, // Get an existing token data func (ctrl *TokenController) Get(ctx context.Context, id, userID string) (*TokenData, error) { log := logger.FromContext(ctx).WithValues("uuid", id, "user_id", userID) - log.V(2).Info("Regenerating a token") - query := ` - SELECT uuid, description, generated_at, expires_at, last_used_at, revoked_at, created_at - FROM tokens - WHERE uuid = $1 AND user_id = $2` - token := &TokenData{} - var generatedAt sql.NullTime - var expiresAt sql.NullTime - var revokedAt sql.NullTime - var lastUsedAt sql.NullTime - var createdAt sql.NullTime + log.V(2).Info("Getting a token") - if err := ctrl.DB.QueryRowContext(ctx, query, id, userID).Scan( - &token.UUID, - &token.Name, - &generatedAt, - &expiresAt, - &lastUsedAt, - &revokedAt, - &createdAt, - ); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, err + queryResult, err := repository.GetToken(ctx, ctrl.DB, id, userID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, ErrTokenNotFound } - log.Error(err, "Couldn't find a token") + log.Error(err, "Couldn't get a token from DB") return nil, ErrServerError } - token.GeneratedAt = generatedAt.Time - token.ExpiresAt = expiresAt.Time - token.RevokedAt = revokedAt.Time - token.LastUsedAt = lastUsedAt.Time - token.CreatedAt = createdAt.Time - return token, nil + scope := map[string][]string{} + if err := json.Unmarshal([]byte(queryResult.Scope), &scope); err != nil { + log.Error(err, "Couldn't unmarshal scope into json") + return nil, ErrServerError + + } + + result := &TokenData{ + UUID: queryResult.UUID, + Name: queryResult.Decsription, + CreatedAt: queryResult.CreatedAt, + LastUsedAt: queryResult.LastUsedAt, + RevokedAt: queryResult.RevokedAt, + ExpiresAt: queryResult.ExpiresAt, + GeneratedAt: queryResult.GeneratedAt, + Scopes: scope, + } + + return result, nil } // List all available token @@ -381,6 +392,7 @@ func hashSHA256(s string) string { return hex.EncodeToString(hash[:]) } +// Unit Tests func TestUnitHashPersistence(t *testing.T) { password := "qwertyu9" hash1 := hashSHA256(password) diff --git a/internal/controllers/tokens_test.go b/internal/controllers/tokens_test.go index d1ad446..69106d8 100644 --- a/internal/controllers/tokens_test.go +++ b/internal/controllers/tokens_test.go @@ -6,6 +6,7 @@ import ( "time" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers" + "github.com/google/uuid" "github.com/stretchr/testify/assert" ) @@ -36,24 +37,16 @@ func TestCreateToken_Success(t *testing.T) { } ctrl := newTestTokensController(t.Context()) - tokenID, err := ctrl.Create(t.Context(), tokenData) + tokenVal, tokenID, err := ctrl.Create(t.Context(), tokenData) assert.NoError(t, err) assert.NotEmpty(t, tokenID) + assert.NotEmpty(t, tokenVal) } -func TestCreateToken_Success(t *testing.T) { - // Create a user for the token - ctrlAccount := newTestAccountController(t.Context()) - accountData := &controllers.AccountData{ - Password: "qwertyu9", - Email: newTestUniqueEmail("accounts"), - } - id, err := ctrlAccount.Create(t.Context(), accountData) - assert.NoError(t, err) - +func TestCreateToken_UserNotExist(t *testing.T) { tokenData := &controllers.TokenData{ Name: "Test Token", - UserID: id, + UserID: uuid.NewString(), ExpiresAt: time.Now().Add(time.Second * 5), Scopes: map[string][]string{ "Test": {"test", "test2"}, @@ -61,7 +54,44 @@ func TestCreateToken_Success(t *testing.T) { } ctrl := newTestTokensController(t.Context()) - tokenID, err := ctrl.Create(t.Context(), tokenData) - assert.NoError(t, err) - assert.NotEmpty(t, tokenID) + tokenVal, tokenID, err := ctrl.Create(t.Context(), tokenData) + + assert.Error(t, err) + assert.ErrorIs(t, err, controllers.ErrUserNotFound) + assert.Empty(t, tokenID) + assert.Empty(t, tokenVal) +} + +func TestGetToken_Success(t *testing.T) { + // Create a user for the token + ctrlAccount := newTestAccountController(t.Context()) + accountData := &controllers.AccountData{ + Password: "qwertyu9", + Email: newTestUniqueEmail("accounts"), + } + + now := time.Now() + userID, err := ctrlAccount.Create(t.Context(), accountData) + assert.NoError(t, err) + + tokenData := &controllers.TokenData{ + Name: "Test Token", + UserID: userID, + ExpiresAt: time.Now().Add(time.Second * 5), + Scopes: map[string][]string{ + "Test": {"test", "test2"}, + }, + } + + ctrl := newTestTokensController(t.Context()) + _, tokenID, err := ctrl.Create(t.Context(), tokenData) + assert.NoError(t, err) + + token, err := ctrl.Get(t.Context(), tokenID, userID) + assert.NoError(t, err) + assert.Equal(t, now.Truncate(time.Second), token.GeneratedAt.Truncate(time.Second)) + assert.Equal(t, now.Truncate(time.Second), token.CreatedAt.Truncate(time.Second)) + assert.Equal(t, tokenData.Scopes, token.Scopes) + assert.Equal(t, tokenData.Name, token.Name) + assert.Equal(t, tokenData.ExpiresAt.Truncate(time.Second), token.ExpiresAt.Truncate(time.Second)) } diff --git a/internal/repository/accounts.go b/internal/repository/accounts.go index 0b259b8..e396e5b 100644 --- a/internal/repository/accounts.go +++ b/internal/repository/accounts.go @@ -60,3 +60,16 @@ func GetUUIDForEmail(ctx context.Context, db *sql.DB, email string) (uuid string } return } + +// IsAccountExist checks if an account with a UUID exists in the db +func IsAccountExist(ctx context.Context, db *sql.DB, uuid string) (bool, error) { + exists := false + err := db.QueryRowContext( + ctx, + `SELECT EXISTS( + SELECT 1 FROM accounts WHERE uuid = $1 + )`, + uuid, + ).Scan(&exists) + return exists, err +} diff --git a/internal/repository/tokens.go b/internal/repository/tokens.go new file mode 100644 index 0000000..5ab0728 --- /dev/null +++ b/internal/repository/tokens.go @@ -0,0 +1,94 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "time" +) + +type TokenData struct { + UUID string + Decsription string + TokenHash string + UserID string + CreatedAt time.Time + GeneratedAt time.Time + ExpiresAt time.Time + RevokedAt time.Time + LastUsedAt time.Time + Scope string +} + +// CreateTokens adds a new token to a database +func CreateToken(ctx context.Context, db *sql.DB, data *TokenData) error { + query := ` + INSERT INTO tokens + (uuid, description, token_hash, user_id, scopes, created_at, generated_at, expires_at) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8);` + if _, err := db.QueryContext( + ctx, + query, + data.UUID, + data.Decsription, + data.TokenHash, + data.UserID, + data.Scope, + data.CreatedAt, + data.GeneratedAt, + data.ExpiresAt, + ); err != nil { + return err + } + return nil +} + +// UpdateToken updates token description and scope +func UpdateToken(ctx context.Context, db *sql.DB, data *TokenData) error { + query := "UPDATE tokens SET description = $1, scopes = $2 WHERE uuid = $3;" + if _, err := db.QueryContext(ctx, query, data.Decsription, data.Scope, data.UUID); err != nil { + return err + } + return nil +} + +func GetToken(ctx context.Context, db *sql.DB, tokenID, userID string) (*TokenData, error) { + query := ` + SELECT + uuid, description, generated_at, expires_at, + last_used_at, revoked_at, created_at, scopes + FROM tokens + WHERE uuid = $1 AND user_id = $2;` + + var generatedAt sql.NullTime + var expiresAt sql.NullTime + var revokedAt sql.NullTime + var lastUsedAt sql.NullTime + var createdAt sql.NullTime + + result := &TokenData{} + + if err := db.QueryRowContext(ctx, query, tokenID, userID).Scan( + &result.UUID, + &result.Decsription, + &generatedAt, + &expiresAt, + &lastUsedAt, + &revokedAt, + &createdAt, + &result.Scope, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + result.GeneratedAt = generatedAt.Time + result.ExpiresAt = expiresAt.Time + result.RevokedAt = revokedAt.Time + result.LastUsedAt = lastUsedAt.Time + result.CreatedAt = createdAt.Time + + return result, nil +}