12 Commits

Author SHA1 Message Date
41c58b400f WIP: keep adding projects
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-26 16:47:00 +02:00
21a4c53e0f Implement list projects
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-22 11:23:04 +02:00
4e617c90ef Add basic health checks
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-22 09:39:25 +02:00
e132e143bd WIP: Fix the create project test
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-21 17:12:44 +02:00
a4ab4fa6c6 WIP: Fix the create project test
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-21 17:03:59 +02:00
891b9854b5 WIP: Add a migration for project membmership
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-21 16:48:12 +02:00
d1d4cba05b WIP: Adding list project
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-21 14:55:34 +02:00
9c558d601b WIP: Keep writing projectS
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-20 13:37:48 +02:00
2285bb10ae WIP: Adding projects
Some checks failed
ci/woodpecker/push/build Pipeline failed
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-19 14:04:26 +02:00
28235765cd Add tests for the transactions
All checks were successful
ci/woodpecker/push/build Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-19 11:32:34 +02:00
c8d3112fbd Restructure the projec and start adding projects
All checks were successful
ci/woodpecker/push/build Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-19 10:55:53 +02:00
25ebb4805e WIP: Start adding projects
Signed-off-by: Nikolai Rodionov <allanger@badhouseplants.net>
2026-05-18 22:15:28 +02:00
29 changed files with 1011 additions and 143 deletions

View File

@@ -61,6 +61,30 @@ tasks:
SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable
cmd: go run main.go migrate --migrations-path=file://migrations cmd: go run main.go migrate --migrations-path=file://migrations
force-migration:
desc: Force migrate to a desired version
vars:
SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable
cmd: "{{ .MIGRATE }} -path=./migrations -database={{ .SOFTPLAYER_DB_CONNECTION_STRING }} force {{ .CLI_ARGS }}"
deps:
- migrate
down-migrations:
desc: Roll back all migrations
vars:
SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable
cmd: "{{ .MIGRATE }} -path=./migrations -database={{ .SOFTPLAYER_DB_CONNECTION_STRING }} down -all"
deps:
- migrate
drop-migrations:
desc: Drop migrations
vars:
SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable
cmd: "{{ .MIGRATE }} -path=./migrations -database={{ .SOFTPLAYER_DB_CONNECTION_STRING }} drop"
deps:
- migrate
run-server-dev: run-server-dev:
desc: Run the local dev server desc: Run the local dev server
deps: deps:
@@ -113,6 +137,8 @@ tasks:
desc: Add a new database migration desc: Add a new database migration
silent: true silent: true
cmd: "{{.MIGRATE}} create -dir migrations -ext sql {{.CLI_ARGS}}" cmd: "{{.MIGRATE}} create -dir migrations -ext sql {{.CLI_ARGS}}"
deps:
- migrate
# Install required tools # Install required tools
localbin: localbin:
@@ -132,6 +158,7 @@ tasks:
TARGET: "{{.MIGRATE}}" TARGET: "{{.MIGRATE}}"
PACKAGE: github.com/golang-migrate/migrate/v4/cmd/migrate PACKAGE: github.com/golang-migrate/migrate/v4/cmd/migrate
VERSION: latest VERSION: latest
TAGS: "postgres"
go-install-tool: go-install-tool:
internal: true internal: true
@@ -150,7 +177,13 @@ tasks:
echo "Downloading $PACKAGE" echo "Downloading $PACKAGE"
rm -f "$TARGET" rm -f "$TARGET"
GOBIN="{{.LOCALBIN}}" go install "$PACKAGE" TAGS="{{.TAGS}}"
if [ -n "$TAGS" ]; then
echo "Using build tags: $TAGS"
GOBIN="{{.LOCALBIN}}" go install -tags "$TAGS" "$PACKAGE"
else
GOBIN="{{.LOCALBIN}}" go install "$PACKAGE"
fi
mv "{{.LOCALBIN}}/$(basename "$TARGET")" "$VERSIONED" mv "{{.LOCALBIN}}/$(basename "$TARGET")" "$VERSIONED"
ln -sf "$(realpath "$VERSIONED")" "$TARGET" ln -sf "$(realpath "$VERSIONED")" "$TARGET"

View File

@@ -2,15 +2,16 @@ package cmd
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"net" "net"
"strings" "strings"
"time" "time"
v1 "gitea.badhouseplants.net/softplayer/softplayer-backend/api/v1" v1 "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/api/v1"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/logger" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/logger"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/postgres" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/postgres"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
accounts "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts/v1" accounts "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts/v1"
test "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/test/v1" test "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/test/v1"
tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1" tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1"
@@ -21,6 +22,9 @@ import (
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/health"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection" "google.golang.org/grpc/reflection"
) )
@@ -44,6 +48,8 @@ type Server struct {
// Dev and logging // Dev and logging
Reflection bool `env:"SOFTPLAYER_REFLECTION" default:"false"` Reflection bool `env:"SOFTPLAYER_REFLECTION" default:"false"`
DevMode bool `env:"SOFTPLAYER_DEV_MODE" default:"false"` DevMode bool `env:"SOFTPLAYER_DEV_MODE" default:"false"`
// HealthChecks
HealthCheckInterval time.Duration `env:"SOFTPLAYER_HEALTH_CHECK_INTERVAL" default:"5s"`
} }
// Run the grpc backend server // Run the grpc backend server
@@ -68,7 +74,7 @@ func (cmd *Server) Run(ctx context.Context) error {
Addr: cmd.RedisHost, Addr: cmd.RedisHost,
}) })
authController := controllers.NewAuthController( authController := services.NewAuthController(
[]byte(cmd.JWTSecret), []byte(cmd.JWTSecret),
cmd.AccessTokenTTL, cmd.AccessTokenTTL,
cmd.RefrestTokenTTL, cmd.RefrestTokenTTL,
@@ -97,12 +103,12 @@ func (cmd *Server) Run(ctx context.Context) error {
reflection.Register(grpcServer) reflection.Register(grpcServer)
} }
tokenCtrl := &controllers.TokenController{ tokenCtrl := &services.TokenController{
DB: db, DB: db,
Redis: rdb, Redis: rdb,
} }
accountCtrl := &controllers.AccountController{ accountCtrl := &services.AccountController{
HashCost: cmd.HashCost, HashCost: cmd.HashCost,
DB: db, DB: db,
DevMode: cmd.DevMode, DevMode: cmd.DevMode,
@@ -120,6 +126,33 @@ func (cmd *Server) Run(ctx context.Context) error {
tokens.RegisterPublicTokensServiceServer(grpcServer, v1.NewPublicTokensServer(tokenCtrl, authController)) tokens.RegisterPublicTokensServiceServer(grpcServer, v1.NewPublicTokensServer(tokenCtrl, authController))
accounts.RegisterPublicAccountsServiceServer(grpcServer, v1.NewPublicAccountServer(accountCtrl, authController)) accounts.RegisterPublicAccountsServiceServer(grpcServer, v1.NewPublicAccountServer(accountCtrl, authController))
healthcheck := health.NewServer()
healthgrpc.RegisterHealthServer(grpcServer, healthcheck)
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
// Example checks
dbOK := checkDatabase(db)
redisOK := checkRedis(rdb)
status := healthpb.HealthCheckResponse_SERVING
if !dbOK || !redisOK {
status = healthpb.HealthCheckResponse_NOT_SERVING
}
healthcheck.SetServingStatus(
"",
status,
)
}
}()
info := grpcServer.GetServiceInfo() info := grpcServer.GetServiceInfo()
tokenCtrl.SetGRPCInfo(info) tokenCtrl.SetGRPCInfo(info)
tokenCtrl.SetRules() tokenCtrl.SetRules()
@@ -144,6 +177,10 @@ func selectorRequireAuth(ctx context.Context, callMeta interceptors.CallMeta) bo
return false return false
} }
if serviceName == "Health" {
return false
}
if strings.Contains(serviceName, "ServerReflection") { if strings.Contains(serviceName, "ServerReflection") {
return false return false
} }
@@ -154,3 +191,33 @@ func selectorRequireAuth(ctx context.Context, callMeta interceptors.CallMeta) bo
return true return true
} }
func checkDatabase(db *sql.DB) bool {
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
// Fast connectivity check
if err := db.PingContext(ctx); err != nil {
return false
}
return true
}
func checkRedis(rdb *redis.Client) bool {
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
err := rdb.Ping(ctx).Err()
if err != nil {
return false
}
return true
}

2
go.mod
View File

@@ -43,7 +43,7 @@ require (
) )
require ( require (
gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260517200845-22f1b32dfad9 gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260518175130-4b27db42e21e
github.com/golang/protobuf v1.5.4 github.com/golang/protobuf v1.5.4
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect

4
go.sum
View File

@@ -2,8 +2,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260517200845-22f1b32dfad9 h1:RP73i+SOZYmc61F+gZjO/rvUlpPP0Za4MLJKAgS+1YI= gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260518175130-4b27db42e21e h1:9pt3cvnJ3slg0lDjCwgVbvS/kI1JlKuNUFxdlYCGWF0=
gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260517200845-22f1b32dfad9/go.mod h1:EcQEZ3NN06b3UmKxiRnQnXDDjQ9kmJgoQQBAS+fpRQw= gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260518175130-4b27db42e21e/go.mod h1:EcQEZ3NN06b3UmKxiRnQnXDDjQ9kmJgoQQBAS+fpRQw=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"errors" "errors"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
accounts "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts/v1" accounts "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts/v1"
"github.com/golang/protobuf/ptypes/empty" "github.com/golang/protobuf/ptypes/empty"
"google.golang.org/grpc" "google.golang.org/grpc"
@@ -15,8 +15,8 @@ import (
) )
func NewAccountServer( func NewAccountServer(
accountsCtrl *controllers.AccountController, accountsCtrl *services.AccountController,
authorizationCtrl *controllers.AuthController, authorizationCtrl *services.AuthController,
) *AccountsServer { ) *AccountsServer {
return &AccountsServer{ return &AccountsServer{
accountsCtrl: accountsCtrl, accountsCtrl: accountsCtrl,
@@ -26,23 +26,23 @@ func NewAccountServer(
type AccountsServer struct { type AccountsServer struct {
accounts.UnimplementedAccountsServiceServer accounts.UnimplementedAccountsServiceServer
accountsCtrl *controllers.AccountController accountsCtrl *services.AccountController
authorizationCtrl *controllers.AuthController authorizationCtrl *services.AuthController
} }
func (srv *AccountsServer) RefreshToken(ctx context.Context, in *empty.Empty) (*empty.Empty, error) { func (srv *AccountsServer) RefreshToken(ctx context.Context, in *empty.Empty) (*empty.Empty, error) {
claims, err := controllers.ClaimsFromContext(ctx) claims, err := services.ClaimsFromContext(ctx)
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid") return nil, status.Error(codes.Aborted, "Context is invalid")
} }
if claims.TokenType != controllers.TokenTypeRefresh { if claims.TokenType != services.TokenTypeRefresh {
return nil, status.Error(codes.Unauthenticated, "Invalid token") return nil, status.Error(codes.Unauthenticated, "Invalid token")
} }
session, err := srv.authorizationCtrl.GetSession(ctx, claims.TokenID) session, err := srv.authorizationCtrl.GetSession(ctx, claims.TokenID)
if err != nil { if err != nil {
if errors.Is(err, controllers.ErrSessionNotFound) { if errors.Is(err, services.ErrSessionNotFound) {
return nil, status.Error(codes.Unauthenticated, "Session doesn't exists") return nil, status.Error(codes.Unauthenticated, "Session doesn't exists")
} }
return nil, status.Error(codes.Internal, "Somethings is broken on our side") return nil, status.Error(codes.Internal, "Somethings is broken on our side")
@@ -52,24 +52,24 @@ func (srv *AccountsServer) RefreshToken(ctx context.Context, in *empty.Empty) (*
return nil, status.Error(codes.Unauthenticated, "Invalid session") return nil, status.Error(codes.Unauthenticated, "Invalid session")
} }
accessToken, _, err := srv.authorizationCtrl.GenerateToken(&controllers.JWTData{ accessToken, _, err := srv.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: claims.UserID, UserID: claims.UserID,
TokenType: controllers.TokenTypeAccess, TokenType: services.TokenTypeAccess,
TokenAud: controllers.TokenAudWeb, TokenAud: services.TokenAudWeb,
}) })
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token") return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
} }
refreshToken, tokenID, err := srv.authorizationCtrl.GenerateToken(&controllers.JWTData{ refreshToken, tokenID, err := srv.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: claims.UserID, UserID: claims.UserID,
TokenType: controllers.TokenTypeRefresh, TokenType: services.TokenTypeRefresh,
TokenAud: controllers.TokenAudWeb, TokenAud: services.TokenAudWeb,
}) })
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token") return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
} }
newSession := &controllers.Session{UserID: session.UserID} newSession := &services.Session{UserID: session.UserID}
if err := srv.authorizationCtrl.SaveSession(ctx, tokenID, newSession); err != nil { if err := srv.authorizationCtrl.SaveSession(ctx, tokenID, newSession); err != nil {
return nil, status.Error(codes.Aborted, "Couldn't store session") return nil, status.Error(codes.Aborted, "Couldn't store session")

View File

@@ -0,0 +1,38 @@
package v1
import (
"context"
projects "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/projects/v1"
"google.golang.org/grpc"
)
func NewProjectsServer() *ProjectsServer {
return &ProjectsServer{}
}
// var _ projects.ProjectsServiceServer = (*ProjectsServer)(nil)
type ProjectsServer struct {
projects.UnimplementedProjectsServiceServer
}
// CreateProject implements [v1.ProjectsServiceServer].
func (p *ProjectsServer) CreateProject(context.Context, *projects.CreateProjectRequest) (*projects.CreateProjectResponse, error) {
panic("unimplemented")
}
// GetProject implements [v1.ProjectsServiceServer].
func (p *ProjectsServer) GetProject(context.Context, *projects.GetProjectRequest) (*projects.GetProjectResponse, error) {
panic("unimplemented")
}
// ListProjects implements [v1.ProjectsServiceServer].
func (p *ProjectsServer) ListProjects(*projects.ListProjectsRequest, grpc.ServerStreamingServer[projects.ListProjectsResponse]) error {
panic("unimplemented")
}
// UpdateProject implements [v1.ProjectsServiceServer].
func (p *ProjectsServer) UpdateProject(context.Context, *projects.UpdateProjectRequest) (*projects.UpdateProjectResponse, error) {
panic("unimplemented")
}

View File

@@ -3,7 +3,7 @@ package v1
import ( import (
"context" "context"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
accounts "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts/v1" accounts "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts/v1"
"github.com/golang/protobuf/ptypes/empty" "github.com/golang/protobuf/ptypes/empty"
"google.golang.org/grpc" "google.golang.org/grpc"
@@ -14,8 +14,8 @@ import (
) )
func NewPublicAccountServer( func NewPublicAccountServer(
accountsCtrl *controllers.AccountController, accountsCtrl *services.AccountController,
authorizationCtrl *controllers.AuthController, authorizationCtrl *services.AuthController,
) *PublicAccountService { ) *PublicAccountService {
return &PublicAccountService{ return &PublicAccountService{
accountsCtrl: accountsCtrl, accountsCtrl: accountsCtrl,
@@ -25,8 +25,8 @@ func NewPublicAccountServer(
type PublicAccountService struct { type PublicAccountService struct {
accounts.UnimplementedPublicAccountsServiceServer accounts.UnimplementedPublicAccountsServiceServer
accountsCtrl *controllers.AccountController accountsCtrl *services.AccountController
authorizationCtrl *controllers.AuthController authorizationCtrl *services.AuthController
} }
func (a *PublicAccountService) SignIn(ctx context.Context, in *accounts.SignInRequest) (*empty.Empty, error) { func (a *PublicAccountService) SignIn(ctx context.Context, in *accounts.SignInRequest) (*empty.Empty, error) {
@@ -34,25 +34,25 @@ func (a *PublicAccountService) SignIn(ctx context.Context, in *accounts.SignInRe
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't create a user") return nil, status.Error(codes.Aborted, "Couldn't create a user")
} }
accessToken, _, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{ accessToken, _, err := a.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: id, UserID: id,
TokenType: controllers.TokenTypeAccess, TokenType: services.TokenTypeAccess,
TokenAud: controllers.TokenAudWeb, TokenAud: services.TokenAudWeb,
}) })
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token") return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
} }
refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{ refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: id, UserID: id,
TokenType: controllers.TokenTypeRefresh, TokenType: services.TokenTypeRefresh,
TokenAud: controllers.TokenAudWeb, TokenAud: services.TokenAudWeb,
}) })
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token") return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
} }
session := &controllers.Session{UserID: id} session := &services.Session{UserID: id}
if err := a.authorizationCtrl.SaveSession(ctx, tokenID, session); err != nil { if err := a.authorizationCtrl.SaveSession(ctx, tokenID, session); err != nil {
return nil, status.Error(codes.Aborted, "Couldn't store session") return nil, status.Error(codes.Aborted, "Couldn't store session")
@@ -69,34 +69,36 @@ func (a *PublicAccountService) SignIn(ctx context.Context, in *accounts.SignInRe
// Create a new account in Softplayer // Create a new account in Softplayer
func (a *PublicAccountService) SignUp(ctx context.Context, in *accounts.SignUpRequest) (*empty.Empty, error) { func (a *PublicAccountService) SignUp(ctx context.Context, in *accounts.SignUpRequest) (*empty.Empty, error) {
data := &controllers.AccountData{ data := &services.AccountData{
Password: in.GetPassword(), Password: in.GetPassword(),
Email: in.GetEmail(), Email: in.GetEmail(),
Name: in.PersonalData.GetName(),
Surname: in.PersonalData.GetSurname(),
} }
id, err := a.accountsCtrl.Create(ctx, data) id, err := a.accountsCtrl.Create(ctx, data)
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't create a user") return nil, status.Error(codes.Aborted, "Couldn't create a user")
} }
accessToken, _, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{ accessToken, _, err := a.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: id, UserID: id,
TokenType: controllers.TokenTypeAccess, TokenType: services.TokenTypeAccess,
TokenAud: controllers.TokenAudWeb, TokenAud: services.TokenAudWeb,
}) })
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token") return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
} }
refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{ refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: id, UserID: id,
TokenType: controllers.TokenTypeRefresh, TokenType: services.TokenTypeRefresh,
TokenAud: controllers.TokenAudWeb, TokenAud: services.TokenAudWeb,
}) })
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token") return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
} }
session := &controllers.Session{UserID: id} session := &services.Session{UserID: id}
if err := a.authorizationCtrl.SaveSession(ctx, tokenID, session); err != nil { if err := a.authorizationCtrl.SaveSession(ctx, tokenID, session); err != nil {
return nil, status.Error(codes.Aborted, "Couldn't store session") return nil, status.Error(codes.Aborted, "Couldn't store session")

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"errors" "errors"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1" tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
@@ -17,13 +17,13 @@ import (
type PublicTokensServer struct { type PublicTokensServer struct {
tokens.UnimplementedPublicTokensServiceServer tokens.UnimplementedPublicTokensServiceServer
tokenCtrl *controllers.TokenController tokenCtrl *services.TokenController
authorizationCtrl *controllers.AuthController authorizationCtrl *services.AuthController
} }
func NewPublicTokensServer( func NewPublicTokensServer(
tokenCtrl *controllers.TokenController, tokenCtrl *services.TokenController,
authorizationCtrl *controllers.AuthController, authorizationCtrl *services.AuthController,
) *PublicTokensServer { ) *PublicTokensServer {
return &PublicTokensServer{ return &PublicTokensServer{
tokenCtrl: tokenCtrl, tokenCtrl: tokenCtrl,
@@ -34,19 +34,19 @@ func NewPublicTokensServer(
func (srv *PublicTokensServer) AuthenticateWithToken(ctx context.Context, in *tokens.AuthenticateWithTokenRequest) (*emptypb.Empty, error) { func (srv *PublicTokensServer) AuthenticateWithToken(ctx context.Context, in *tokens.AuthenticateWithTokenRequest) (*emptypb.Empty, error) {
tokenAuthRes, err := srv.tokenCtrl.AuthenticateWithToken(ctx, in.TokenValue.Token) tokenAuthRes, err := srv.tokenCtrl.AuthenticateWithToken(ctx, in.TokenValue.Token)
if err != nil { if err != nil {
if errors.Is(err, controllers.ErrBadToken) { if errors.Is(err, services.ErrBadToken) {
return nil, status.Error(codes.Unauthenticated, "Token is not valid") return nil, status.Error(codes.Unauthenticated, "Token is not valid")
} }
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side") return nil, status.Error(codes.Internal, "Something is broken on our side")
} }
return nil, status.Error(codes.Aborted, "Couldn't authorize") return nil, status.Error(codes.Aborted, "Couldn't authorize")
} }
jwtData := &controllers.JWTData{ jwtData := &services.JWTData{
UserID: tokenAuthRes.UserID, UserID: tokenAuthRes.UserID,
TokenType: controllers.TokenTypeAccess, TokenType: services.TokenTypeAccess,
TokenAud: controllers.TokenAudToken, TokenAud: services.TokenAudToken,
Scope: tokenAuthRes.Scope, Scope: tokenAuthRes.Scope,
} }
accessToken, _, err := srv.authorizationCtrl.GenerateToken(jwtData) accessToken, _, err := srv.authorizationCtrl.GenerateToken(jwtData)

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"errors" "errors"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1" tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
@@ -18,13 +18,13 @@ import (
// TokensServer implements the Token Service // TokensServer implements the Token Service
type TokensServer struct { type TokensServer struct {
tokens.UnimplementedTokensServiceServer tokens.UnimplementedTokensServiceServer
tokenCtrl *controllers.TokenController tokenCtrl *services.TokenController
authorizationCtrl *controllers.AuthController authorizationCtrl *services.AuthController
} }
func NewTokensServer( func NewTokensServer(
tokenCtrl *controllers.TokenController, tokenCtrl *services.TokenController,
authorizationCtrl *controllers.AuthController, authorizationCtrl *services.AuthController,
) *TokensServer { ) *TokensServer {
return &TokensServer{ return &TokensServer{
tokenCtrl: tokenCtrl, tokenCtrl: tokenCtrl,
@@ -34,7 +34,7 @@ func NewTokensServer(
// CreateToken implements [v1.TokensServiceServer]. // CreateToken implements [v1.TokensServiceServer].
func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateTokenRequest) (*tokens.CreateTokenResponse, error) { func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateTokenRequest) (*tokens.CreateTokenResponse, error) {
claims, err := controllers.ClaimsFromContext(ctx) claims, err := services.ClaimsFromContext(ctx)
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid") return nil, status.Error(codes.Aborted, "Context is invalid")
} }
@@ -50,7 +50,7 @@ func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateToken
permissions[service] = methods.GetMethods() permissions[service] = methods.GetMethods()
} }
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: in.TokenMetadata.GetName(), Name: in.TokenMetadata.GetName(),
UserID: claims.UserID, UserID: claims.UserID,
ExpiresAt: in.TokenMetadata.ExpiresAt.AsTime(), ExpiresAt: in.TokenMetadata.ExpiresAt.AsTime(),
@@ -59,7 +59,7 @@ func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateToken
token, tokenID, err := srv.tokenCtrl.Create(ctx, tokenData) token, tokenID, err := srv.tokenCtrl.Create(ctx, tokenData)
if err != nil { if err != nil {
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side") return nil, status.Error(codes.Internal, "Something is broken on our side")
} }
return nil, status.Error(codes.Aborted, "Couldn't create a token") return nil, status.Error(codes.Aborted, "Couldn't create a token")
@@ -75,7 +75,7 @@ func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateToken
// ForceTokenExpiration implements [v1.TokensServiceServer]. // ForceTokenExpiration implements [v1.TokensServiceServer].
func (srv *TokensServer) ForceTokenExpiration(ctx context.Context, in *tokens.ForceTokenExpirationRequest) (*emptypb.Empty, error) { func (srv *TokensServer) ForceTokenExpiration(ctx context.Context, in *tokens.ForceTokenExpirationRequest) (*emptypb.Empty, error) {
claims, err := controllers.ClaimsFromContext(ctx) claims, err := services.ClaimsFromContext(ctx)
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid") return nil, status.Error(codes.Aborted, "Context is invalid")
} }
@@ -84,14 +84,14 @@ func (srv *TokensServer) ForceTokenExpiration(ctx context.Context, in *tokens.Fo
} }
if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil { if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil {
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side") return nil, status.Error(codes.Internal, "Something is broken on our side")
} }
return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token") return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token")
} }
if err := srv.tokenCtrl.ForceExpiration(ctx, in.TokenUuid.GetUuid()); err != nil { if err := srv.tokenCtrl.ForceExpiration(ctx, in.TokenUuid.GetUuid()); err != nil {
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side") return nil, status.Error(codes.Internal, "Something is broken on our side")
} }
return nil, status.Error(codes.Aborted, "Couldn't create a token") return nil, status.Error(codes.Aborted, "Couldn't create a token")
@@ -101,7 +101,7 @@ func (srv *TokensServer) ForceTokenExpiration(ctx context.Context, in *tokens.Fo
// GetToken implements [v1.TokensServiceServer]. // GetToken implements [v1.TokensServiceServer].
func (srv *TokensServer) GetToken(ctx context.Context, in *tokens.GetTokenRequest) (*tokens.GetTokenResponse, error) { func (srv *TokensServer) GetToken(ctx context.Context, in *tokens.GetTokenRequest) (*tokens.GetTokenResponse, error) {
claims, err := controllers.ClaimsFromContext(ctx) claims, err := services.ClaimsFromContext(ctx)
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid") return nil, status.Error(codes.Aborted, "Context is invalid")
} }
@@ -109,7 +109,7 @@ func (srv *TokensServer) GetToken(ctx context.Context, in *tokens.GetTokenReques
return nil, status.Error(codes.Aborted, "Context is invalid") return nil, status.Error(codes.Aborted, "Context is invalid")
} }
if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil { if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil {
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side") return nil, status.Error(codes.Internal, "Something is broken on our side")
} }
return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token") return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token")
@@ -117,7 +117,7 @@ func (srv *TokensServer) GetToken(ctx context.Context, in *tokens.GetTokenReques
token, err := srv.tokenCtrl.Get(ctx, in.TokenUuid.Uuid, claims.UserID) token, err := srv.tokenCtrl.Get(ctx, in.TokenUuid.Uuid, claims.UserID)
if err != nil { if err != nil {
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side") return nil, status.Error(codes.Internal, "Something is broken on our side")
} }
return nil, status.Error(codes.Aborted, "Couldn't list tokens") return nil, status.Error(codes.Aborted, "Couldn't list tokens")
@@ -141,7 +141,7 @@ func (srv *TokensServer) GetToken(ctx context.Context, in *tokens.GetTokenReques
// ListTokens implements [v1.TokensServiceServer]. // ListTokens implements [v1.TokensServiceServer].
func (srv *TokensServer) ListTokens(in *emptypb.Empty, stream grpc.ServerStreamingServer[tokens.ListTokensResponse]) error { func (srv *TokensServer) ListTokens(in *emptypb.Empty, stream grpc.ServerStreamingServer[tokens.ListTokensResponse]) error {
claims, err := controllers.ClaimsFromContext(stream.Context()) claims, err := services.ClaimsFromContext(stream.Context())
if err != nil { if err != nil {
return status.Error(codes.Aborted, "Context is invalid") return status.Error(codes.Aborted, "Context is invalid")
} }
@@ -151,7 +151,7 @@ func (srv *TokensServer) ListTokens(in *emptypb.Empty, stream grpc.ServerStreami
tokensRes, err := srv.tokenCtrl.List(stream.Context(), claims.UserID) tokensRes, err := srv.tokenCtrl.List(stream.Context(), claims.UserID)
if err != nil { if err != nil {
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return status.Error(codes.Internal, "Something is broken on our side") return status.Error(codes.Internal, "Something is broken on our side")
} }
return status.Error(codes.Aborted, "Couldn't list tokens") return status.Error(codes.Aborted, "Couldn't list tokens")
@@ -179,7 +179,7 @@ func (srv *TokensServer) ListTokens(in *emptypb.Empty, stream grpc.ServerStreami
// RegenerateToken implements [v1.TokensServiceServer]. // RegenerateToken implements [v1.TokensServiceServer].
func (srv *TokensServer) RegenerateToken(ctx context.Context, in *tokens.RegenerateTokenRequest) (*tokens.RegenerateTokenResponse, error) { func (srv *TokensServer) RegenerateToken(ctx context.Context, in *tokens.RegenerateTokenRequest) (*tokens.RegenerateTokenResponse, error) {
claims, err := controllers.ClaimsFromContext(ctx) claims, err := services.ClaimsFromContext(ctx)
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid") return nil, status.Error(codes.Aborted, "Context is invalid")
} }
@@ -187,7 +187,7 @@ func (srv *TokensServer) RegenerateToken(ctx context.Context, in *tokens.Regener
return nil, status.Error(codes.Aborted, "Context is invalid") return nil, status.Error(codes.Aborted, "Context is invalid")
} }
if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil { if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil {
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side") return nil, status.Error(codes.Internal, "Something is broken on our side")
} }
return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token") return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token")
@@ -195,7 +195,7 @@ func (srv *TokensServer) RegenerateToken(ctx context.Context, in *tokens.Regener
tokenVal, err := srv.tokenCtrl.Regenerate(ctx, in.TokenUuid.GetUuid()) tokenVal, err := srv.tokenCtrl.Regenerate(ctx, in.TokenUuid.GetUuid())
if err != nil { if err != nil {
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side") return nil, status.Error(codes.Internal, "Something is broken on our side")
} }
return nil, status.Error(codes.Aborted, "Couldn't list tokens") return nil, status.Error(codes.Aborted, "Couldn't list tokens")
@@ -209,7 +209,7 @@ func (srv *TokensServer) RegenerateToken(ctx context.Context, in *tokens.Regener
// UpdateToken implements [v1.TokensServiceServer]. // UpdateToken implements [v1.TokensServiceServer].
func (srv *TokensServer) UpdateToken(ctx context.Context, in *tokens.UpdateTokenRequest) (*tokens.UpdateTokenResponse, error) { func (srv *TokensServer) UpdateToken(ctx context.Context, in *tokens.UpdateTokenRequest) (*tokens.UpdateTokenResponse, error) {
claims, err := controllers.ClaimsFromContext(ctx) claims, err := services.ClaimsFromContext(ctx)
if err != nil { if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid") return nil, status.Error(codes.Aborted, "Context is invalid")
} }
@@ -218,7 +218,7 @@ func (srv *TokensServer) UpdateToken(ctx context.Context, in *tokens.UpdateToken
} }
if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil { if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil {
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side") return nil, status.Error(codes.Internal, "Something is broken on our side")
} }
return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token") return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token")
@@ -231,12 +231,12 @@ func (srv *TokensServer) UpdateToken(ctx context.Context, in *tokens.UpdateToken
for service, methods := range in.TokenPermissions.Permissions { for service, methods := range in.TokenPermissions.Permissions {
permissions[service] = methods.GetMethods() permissions[service] = methods.GetMethods()
} }
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: in.TokenMetadata.Name, Name: in.TokenMetadata.Name,
Scopes: permissions, Scopes: permissions,
} }
if err := srv.tokenCtrl.Update(ctx, tokenData); err != nil { if err := srv.tokenCtrl.Update(ctx, tokenData); err != nil {
if errors.Is(err, controllers.ErrServerError) { if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side") return nil, status.Error(codes.Internal, "Something is broken on our side")
} }
return nil, status.Error(codes.Aborted, "Couldn't list tokens") return nil, status.Error(codes.Aborted, "Couldn't list tokens")

View File

@@ -10,21 +10,24 @@ import (
) )
var ( var (
ErrAlreadyExists = errors.New("entry already exists") ErrAlreadyExists = errors.New("entry already exists")
ErrNotFound = errors.New("entry not found") ErrCheckNotPassed = errors.New("sql checks not passed")
ErrNotFound = errors.New("entry not found")
) )
type AccountData struct { type AccountData struct {
UUID string UUID string
Email string Email string
PasswordHash string PasswordHash string
Name string
Surname string
} }
// CreateAccount adds a new account to a database // CreateAccount adds a new account to a database
func CreateAccount(ctx context.Context, db *sql.DB, account *AccountData) error { func CreateAccount(ctx context.Context, db *sql.DB, account *AccountData) error {
query := "INSERT INTO accounts (uuid, email, password_hash) VALUES ($1, $2, $3)" query := "INSERT INTO accounts (uuid, email, password_hash, name, surname) VALUES ($1, $2, $3, $4, $5)"
if _, err := db.ExecContext(ctx, query, account.UUID, account.Email, account.PasswordHash); err != nil { if _, err := db.ExecContext(ctx, query, account.UUID, account.Email, account.PasswordHash, account.Name, account.Surname); err != nil {
var pgErr *pgconn.PgError var pgErr *pgconn.PgError
if errors.As(err, &pgErr) { if errors.As(err, &pgErr) {
if pgErr.Code == pgerrcode.UniqueViolation { if pgErr.Code == pgerrcode.UniqueViolation {

View File

@@ -0,0 +1,169 @@
package repository
import (
"context"
"database/sql"
"errors"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5/pgconn"
)
type ProjectData struct {
UUID string
Name string
Slug string
Description string
CreatedBy string
CreatedAt time.Time
ClosedAt sql.NullTime
Blocked bool
UpdatedAt time.Time
UpdatedBy string
}
// CreateProject adds a new projects to the database
func CreateProject(ctx context.Context, db *sql.DB, data *ProjectData) error {
tx, err := StartTransaction(ctx, db)
if err != nil {
return err
}
defer tx.Rollback()
queryProject := `
INSERT INTO projects
(uuid, name, slug, description, owner_user_id, billing_account_id, created_by, created_at, updated_by, updated_at)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
if _, err := tx.ExecContext(ctx, queryProject,
data.UUID, data.Name, data.Slug, data.Description, data.CreatedBy,
data.CreatedBy, data.CreatedBy, data.CreatedAt, data.CreatedBy, data.CreatedAt); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case pgerrcode.UniqueViolation:
return ErrAlreadyExists
case pgerrcode.CheckViolation:
return ErrCheckNotPassed
}
return err
}
return err
}
// When a project is created, we need to insert the default owner project membership
queryMembership := `
INSERT INTO project_membership
(project_uuid, user_uuid, role, status, invited_by, joined_at)
VALUES
($1, $2, $3, $4, $5, $6);
`
if _, err := tx.ExecContext(ctx, queryMembership,
data.UUID, data.CreatedBy, "owner", "active", data.CreatedBy, data.CreatedAt,
); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case pgerrcode.UniqueViolation:
return ErrAlreadyExists
case pgerrcode.CheckViolation:
return ErrCheckNotPassed
}
return err
}
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
// GetProjectByID returns a project from the database
func GetProjectByID(ctx context.Context, db *sql.DB, projectID string) (*ProjectData, error) {
data := &ProjectData{}
query := `
SELECT uuid, name, slug, description, owner_user_id, closed_at, created_at
FROM projects
WHERE uuid=$1`
if err := db.QueryRowContext(ctx, query, projectID).Scan(
&data.UUID,
&data.Name,
&data.Slug,
&data.Description,
&data.CreatedBy,
&data.ClosedAt,
&data.CreatedAt,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return data, nil
}
// UpdateProject change editable project data
func UpdateProject(ctx context.Context, db *sql.DB, data *ProjectData) error {
query := `
UPDATE projects
SET
name = $2,
description = $3,
updated_at = $4,
updated_by = $5
WHERE uuid = $1;`
if _, err := db.Query(query, data.UUID, data.Name, data.Description, data.UpdatedAt, data.UpdatedBy); err != nil {
return err
}
return nil
}
// ListProjects get all projects that are available for the user from the database
func ListProjects(ctx context.Context, db *sql.DB, userID string) ([]*ProjectData, error) {
query := `
SELECT p.uuid, p.name
FROM projects p
JOIN project_membership pm ON pm.project_uuid = p.uuid
WHERE pm.user_uuid = $1`
rows, err := db.QueryContext(ctx, query, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
result := []*ProjectData{}
for rows.Next() {
pd := &ProjectData{}
err := rows.Scan(&pd.UUID, &pd.Name)
if err != nil {
return nil, err
}
result = append(result, pd)
}
return result, nil
}
// GetProjectOwner should return an owner if a project
func GetProjectOwner(ctx context.Context, db *sql.DB, projectID string) (userID string, err error) {
query := `
SELECT user_uuid
FROM project_membership
WHERE project_uuid = $1 AND role = 'owner'`
if err := db.QueryRowContext(ctx, query, projectID).Scan(
&userID,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
return
}

View File

@@ -0,0 +1,246 @@
package repository_test
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func newTestUniqueEmail(prefix string) string {
if prefix == "" {
prefix = "test"
}
return fmt.Sprintf(
"%s-%d-%s@example.com",
prefix,
time.Now().UnixMilli(),
uuid.NewString(),
)
}
func accountForProject(ctx context.Context) (string, error) {
account := &repository.AccountData{
UUID: uuid.NewString(),
Email: newTestUniqueEmail("projects"),
PasswordHash: "dummy",
Name: "John",
Surname: "Doe",
}
if err := repository.CreateAccount(ctx, newTestDBConnection(ctx), account); err != nil {
return "", err
}
return account.UUID, nil
}
func TestIntegrationProjectsCreate_Success(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-1",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project))
}
func TestIntegrationProjectsCreate_CheckFailed(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-2",
Slug: "test_2",
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.Error(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project), repository.ErrCheckNotPassed)
}
func TestIntegrationProjectsCreate_AlreadyExistsFail(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-3",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project))
assert.Error(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project), repository.ErrAlreadyExists)
}
func TestIntegrationProjectsCreateAndGet_Success(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-4",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project))
data, err := repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), project.UUID)
assert.NoError(t, err)
assert.Equal(t, project.Name, data.Name)
assert.Equal(t, project.Slug, data.Slug)
assert.Equal(t, project.Description, data.Description)
assert.Equal(t, project.CreatedBy, data.CreatedBy)
assert.Equal(t, project.ClosedAt.Time.Truncate(time.Second), data.ClosedAt.Time.Truncate(time.Second))
assert.Equal(t, project.CreatedAt.Truncate(time.Second), data.CreatedAt.Truncate(time.Second))
}
func TestIntegrationProjectsCreateAndGet_NotFound(t *testing.T) {
data, err := repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), uuid.NewString())
assert.ErrorIs(t, err, repository.ErrNotFound)
assert.Nil(t, data)
}
func TestIntegrationProjectsCreateUpdateAndGet_Success(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-5",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project))
data, err := repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), project.UUID)
assert.NoError(t, err)
assert.Equal(t, project.Name, data.Name)
assert.Equal(t, project.Slug, data.Slug)
assert.Equal(t, project.Description, data.Description)
assert.Equal(t, project.CreatedBy, data.CreatedBy)
assert.Equal(t, project.ClosedAt.Time.Truncate(time.Second), data.ClosedAt.Time.Truncate(time.Second))
assert.Equal(t, project.CreatedAt.Truncate(time.Second), data.CreatedAt.Truncate(time.Second))
project.UpdatedBy = uuid.NewString()
project.UpdatedAt = time.Now()
project.Description = "Updated description"
project.Name = "update name"
assert.NoError(t, repository.UpdateProject(t.Context(), newTestDBConnection(t.Context()), project))
data, err = repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), project.UUID)
assert.NoError(t, err)
assert.Equal(t, project.Name, data.Name)
assert.Equal(t, project.Slug, data.Slug)
assert.Equal(t, project.Description, data.Description)
}
func TestIntegrationProjectsCreateAndGetOwner_Success(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-6",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project))
userID, err := repository.GetProjectOwner(t.Context(), newTestDBConnection(t.Context()), project.UUID)
assert.NoError(t, err)
assert.Equal(t, project.CreatedBy, userID)
}
func TestIntegrationListProjectsWorkflow(t *testing.T) {
createdBy1, err := accountForProject(t.Context())
assert.NoError(t, err)
project1 := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "List 1",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy1,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy1,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project1))
project2 := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "List 2",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy1,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy1,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project2))
createdBy2, err := accountForProject(t.Context())
assert.NoError(t, err)
project3 := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "List 3",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy2,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy2,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project3))
projects, err := repository.ListProjects(t.Context(), newTestDBConnection(t.Context()), createdBy1)
assert.NoError(t, err)
assert.Len(t, projects, 2)
projects, err = repository.ListProjects(t.Context(), newTestDBConnection(t.Context()), createdBy2)
assert.NoError(t, err)
assert.Len(t, projects, 1)
}

View File

@@ -0,0 +1,22 @@
package repository
import (
"context"
"database/sql"
)
func StartTransaction(ctx context.Context, db *sql.DB) (*sql.Tx, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return tx, nil
}
func CommitTransaction(ctx context.Context, tx *sql.Tx) error {
return tx.Commit()
}
func RollBackTransaction(ctx context.Context, tx *sql.Tx) error {
return tx.Rollback()
}

View File

@@ -0,0 +1,95 @@
package repository_test
import (
"context"
"database/sql"
"os"
"testing"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/postgres"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository"
"github.com/stretchr/testify/assert"
)
func newTestDBConnection(ctx context.Context) *sql.DB {
connStr, ok := os.LookupEnv("SOFTPLAYER_DB_CONNECTION_STRING")
if !ok {
// Default connection string
connStr = "postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable"
}
db, err := postgres.Open(ctx, connStr)
if err != nil {
panic(err)
}
return db
}
func TestTransactionFlow_Success(t *testing.T) {
db := newTestDBConnection(t.Context())
tx, err := repository.StartTransaction(t.Context(), db)
assert.NoError(t, err)
query1 := "CREATE TABLE transaction_test_success (id SERIAL PRIMARY KEY, data VARCHAR (50) NOT NULL)"
_, err = db.Exec(query1)
assert.NoError(t, err)
data1 := "data1"
data2 := "data2"
query2 := "INSERT INTO transaction_test_success (data) VALUES ($1)"
_, err = tx.Exec(query2, data1)
assert.NoError(t, err)
_, err = tx.Exec(query2, data2)
assert.NoError(t, err)
assert.NoError(t, repository.CommitTransaction(t.Context(), tx))
exists := false
queryTest := `SELECT EXISTS(
SELECT 1 FROM transaction_test_success WHERE data = $1
)`
err = db.QueryRow(queryTest, data1).Scan(&exists)
assert.NoError(t, err)
assert.True(t, exists)
err = db.QueryRow(queryTest, data2).Scan(&exists)
assert.NoError(t, err)
assert.True(t, exists)
queryCleanup := "DROP TABLE transaction_test_success"
_, err = db.Exec(queryCleanup)
assert.NoError(t, err)
}
func TestTransactionFlow_Rollback(t *testing.T) {
db := newTestDBConnection(t.Context())
tx, err := repository.StartTransaction(t.Context(), db)
assert.NoError(t, err)
query1 := "CREATE TABLE transaction_test_rollback (id SERIAL PRIMARY KEY, data VARCHAR (50) NOT NULL)"
_, err = db.Exec(query1)
assert.NoError(t, err)
data1 := "data1"
data2 := "data2"
query2 := "INSERT INTO transaction_test_rollback (data) VALUES ($1)"
_, err = tx.Exec(query2, data1)
assert.NoError(t, err)
_, err = tx.Exec(query2, data2)
assert.NoError(t, err)
assert.NoError(t, repository.RollBackTransaction(t.Context(), tx))
exists := false
queryTest := `SELECT EXISTS(
SELECT 1 FROM transaction_test_rollback WHERE data = $1
)`
err = db.QueryRow(queryTest, data1).Scan(&exists)
assert.NoError(t, err)
assert.False(t, exists)
err = db.QueryRow(queryTest, data2).Scan(&exists)
assert.NoError(t, err)
assert.False(t, exists)
queryCleanup := "DROP TABLE transaction_test_rollback"
_, err = db.Exec(queryCleanup)
assert.NoError(t, err)
}

View File

@@ -1,4 +1,4 @@
package controllers package services
import ( import (
"context" "context"
@@ -41,6 +41,8 @@ type AccountData struct {
Password string Password string
Email string Email string
UUID string UUID string
Name string
Surname string
} }
// Create a new account // Create a new account
@@ -59,6 +61,8 @@ func (c *AccountController) Create(ctx context.Context, data *AccountData) (stri
UUID: data.UUID, UUID: data.UUID,
Email: data.Email, Email: data.Email,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Name: data.Name,
Surname: data.Surname,
} }
if err := repository.CreateAccount(ctx, c.DB, queryData); err != nil { if err := repository.CreateAccount(ctx, c.DB, queryData); err != nil {

View File

@@ -1,4 +1,4 @@
package controllers_test package services_test
import ( import (
"context" "context"
@@ -8,8 +8,8 @@ import (
"testing" "testing"
"time" "time"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/postgres" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/postgres"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
"github.com/google/uuid" "github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -40,8 +40,8 @@ func newTestRedisConnection() *redis.Client {
}) })
} }
func newTestAccountController(ctx context.Context) *controllers.AccountController { func newTestAccountController(ctx context.Context) *services.AccountController {
return &controllers.AccountController{ return &services.AccountController{
DB: newTestDBConnection(ctx), DB: newTestDBConnection(ctx),
Redis: newTestRedisConnection(), Redis: newTestRedisConnection(),
DevMode: true, DevMode: true,
@@ -67,9 +67,11 @@ func newTestUniqueEmail(prefix string) string {
func TestIntegrationAccountCreate_Success(t *testing.T) { func TestIntegrationAccountCreate_Success(t *testing.T) {
ctrl := newTestAccountController(t.Context()) ctrl := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
Surname: "Doe",
Name: "John",
} }
id, err := ctrl.Create(t.Context(), accountData) id, err := ctrl.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
@@ -79,9 +81,11 @@ func TestIntegrationAccountCreate_Success(t *testing.T) {
func TestIntegrationAccountCreate_ExistingAccountErr(t *testing.T) { func TestIntegrationAccountCreate_ExistingAccountErr(t *testing.T) {
ctrl := newTestAccountController(t.Context()) ctrl := newTestAccountController(t.Context())
email := newTestUniqueEmail("accounts") email := newTestUniqueEmail("accounts")
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: email, Email: email,
Surname: "Doe",
Name: "John",
} }
id, err := ctrl.Create(t.Context(), accountData) id, err := ctrl.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
@@ -90,13 +94,13 @@ func TestIntegrationAccountCreate_ExistingAccountErr(t *testing.T) {
id, err = ctrl.Create(t.Context(), accountData) id, err = ctrl.Create(t.Context(), accountData)
assert.Empty(t, id) assert.Empty(t, id)
assert.Error(t, err) assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrEmailUsed) assert.ErrorIs(t, err, services.ErrEmailUsed)
} }
func TestIntegrationAccountLogin_Success(t *testing.T) { func TestIntegrationAccountLogin_Success(t *testing.T) {
ctrl := newTestAccountController(t.Context()) ctrl := newTestAccountController(t.Context())
email := newTestUniqueEmail("accounts") email := newTestUniqueEmail("accounts")
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: email, Email: email,
} }
@@ -113,7 +117,7 @@ func TestIntegrationAccountLogin_Success(t *testing.T) {
func TestIntegrationAccountLogin_WrongPassword(t *testing.T) { func TestIntegrationAccountLogin_WrongPassword(t *testing.T) {
ctrl := newTestAccountController(t.Context()) ctrl := newTestAccountController(t.Context())
email := newTestUniqueEmail("accounts") email := newTestUniqueEmail("accounts")
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: email, Email: email,
} }
@@ -125,13 +129,13 @@ func TestIntegrationAccountLogin_WrongPassword(t *testing.T) {
id, err = ctrl.Login(t.Context(), accountData.Email, "Wrong Password") id, err = ctrl.Login(t.Context(), accountData.Email, "Wrong Password")
assert.Empty(t, id) assert.Empty(t, id)
assert.Error(t, err) assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrWrongPassword) assert.ErrorIs(t, err, services.ErrWrongPassword)
} }
func TestIntegrationAccountLogin_WrongEmail(t *testing.T) { func TestIntegrationAccountLogin_WrongEmail(t *testing.T) {
ctrl := newTestAccountController(t.Context()) ctrl := newTestAccountController(t.Context())
email := newTestUniqueEmail("accounts") email := newTestUniqueEmail("accounts")
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: email, Email: email,
} }
@@ -143,5 +147,5 @@ func TestIntegrationAccountLogin_WrongEmail(t *testing.T) {
id, err = ctrl.Login(t.Context(), "some@email.com", "Wrong Password") id, err = ctrl.Login(t.Context(), "some@email.com", "Wrong Password")
assert.Empty(t, id) assert.Empty(t, id)
assert.Error(t, err) assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrUserNotFound) assert.ErrorIs(t, err, services.ErrUserNotFound)
} }

View File

@@ -1,4 +1,4 @@
package controllers package services
import ( import (
"context" "context"

View File

@@ -1,10 +1,10 @@
package controllers_test package services_test
import ( import (
"testing" "testing"
"time" "time"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -16,24 +16,24 @@ var (
) )
func TestGenerateInvalidTokenType(t *testing.T) { func TestGenerateInvalidTokenType(t *testing.T) {
data := &controllers.JWTData{ data := &services.JWTData{
UserID: testUserID, UserID: testUserID,
TokenType: "invalid_type", TokenType: "invalid_type",
} }
authCtrl := controllers.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil) authCtrl := services.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil)
token, _, err := authCtrl.GenerateToken(data) token, _, err := authCtrl.GenerateToken(data)
assert.Equal(t, "", token) assert.Equal(t, "", token)
assert.ErrorIs(t, controllers.ErrUnknownTokenType, err) assert.ErrorIs(t, services.ErrUnknownTokenType, err)
} }
func TestGenerateValidateAccessToken(t *testing.T) { func TestGenerateValidateAccessToken(t *testing.T) {
data := &controllers.JWTData{ data := &services.JWTData{
UserID: testUserID, UserID: testUserID,
TokenType: controllers.TokenTypeAccess, TokenType: services.TokenTypeAccess,
} }
authCtrl := controllers.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil) authCtrl := services.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil)
now := time.Now() now := time.Now()
token, _, err := authCtrl.GenerateToken(data) token, _, err := authCtrl.GenerateToken(data)
assert.NoError(t, err) assert.NoError(t, err)
@@ -43,18 +43,18 @@ func TestGenerateValidateAccessToken(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, testUserID, claims.UserID) assert.Equal(t, testUserID, claims.UserID)
assert.NotEmpty(t, claims.TokenID) assert.NotEmpty(t, claims.TokenID)
assert.Equal(t, controllers.TokenTypeAccess, claims.TokenType) assert.Equal(t, services.TokenTypeAccess, claims.TokenType)
assert.Equal(t, now.Add(testAccessTTL).Unix(), claims.ExpiresAt.Unix()) assert.Equal(t, now.Add(testAccessTTL).Unix(), claims.ExpiresAt.Unix())
assert.Equal(t, now.Unix(), claims.IssuedAt.Unix()) assert.Equal(t, now.Unix(), claims.IssuedAt.Unix())
assert.Equal(t, now.Unix(), claims.NotBefore.Unix()) assert.Equal(t, now.Unix(), claims.NotBefore.Unix())
} }
func TestGenerateValidateRefreshToken(t *testing.T) { func TestGenerateValidateRefreshToken(t *testing.T) {
data := &controllers.JWTData{ data := &services.JWTData{
UserID: testUserID, UserID: testUserID,
TokenType: controllers.TokenTypeRefresh, TokenType: services.TokenTypeRefresh,
} }
authCtrl := controllers.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil) authCtrl := services.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil)
now := time.Now() now := time.Now()
token, _, err := authCtrl.GenerateToken(data) token, _, err := authCtrl.GenerateToken(data)
assert.NoError(t, err) assert.NoError(t, err)
@@ -64,7 +64,7 @@ func TestGenerateValidateRefreshToken(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, testUserID, claims.UserID) assert.Equal(t, testUserID, claims.UserID)
assert.NotEmpty(t, claims.TokenID) assert.NotEmpty(t, claims.TokenID)
assert.Equal(t, controllers.TokenTypeRefresh, claims.TokenType) assert.Equal(t, services.TokenTypeRefresh, claims.TokenType)
assert.Equal(t, now.Add(testRefreshTTL).Unix(), claims.ExpiresAt.Unix()) assert.Equal(t, now.Add(testRefreshTTL).Unix(), claims.ExpiresAt.Unix())
assert.Equal(t, now.Unix(), claims.IssuedAt.Unix()) assert.Equal(t, now.Unix(), claims.IssuedAt.Unix())
assert.Equal(t, now.Unix(), claims.NotBefore.Unix()) assert.Equal(t, now.Unix(), claims.NotBefore.Unix())

View File

@@ -0,0 +1,71 @@
package services
import (
"context"
"database/sql"
"errors"
"time"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/logger"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository"
"github.com/google/uuid"
)
type ProjectsController struct {
DB *sql.DB
}
type ProjectData struct {
UUID string
Name string
Slug string
Description string
}
var (
ErrProjectExists = errors.New("project exists")
ErrInvalidProject = errors.New("invalid project data")
)
// Create a new project
func (ctrl *ProjectsController) Create(ctx context.Context, data *ProjectData, userID string) (id string, err error) {
id = uuid.NewString()
log := logger.FromContext(ctx).WithValues("id", id)
log.V(2).Info("Creating a project")
queryData := &repository.ProjectData{
UUID: id,
Name: data.Name,
Slug: data.Slug,
Description: data.Description,
CreatedBy: userID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
UpdatedBy: userID,
}
err = repository.CreateProject(ctx, ctrl.DB, queryData)
if err != nil {
if errors.Is(err, repository.ErrAlreadyExists) {
return "", ErrProjectExists
} else if errors.Is(err, repository.ErrCheckNotPassed) {
return "", ErrInvalidProject
}
log.Error(err, "Couldn't create a project")
return "", ErrServerError
}
return id, nil
}
// Update an existing project
func (ctrl *ProjectsController) Update(ctx context.Context, data *ProjectData) error {
return nil
}
// Get an existing project by ID
func (ctrl *ProjectsController) Get(ctx context.Context, projectID string) (data *ProjectData, err error) {
return nil, nil
}
// List projects available for a user
func (ctrl *ProjectsController) List(ctx context.Context) (data []*ProjectData, err error) {
return nil, nil
}

View File

@@ -0,0 +1,38 @@
package services_test
import (
"context"
"testing"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func newTestProjectsController(ctx context.Context) *services.ProjectsController {
return &services.ProjectsController{
DB: newTestDBConnection(ctx),
}
}
func TestIntegrationCreateProject_Success(t *testing.T) {
ctrlAccount := newTestAccountController(t.Context())
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("projects"),
}
userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
ctrlProjects := newTestProjectsController(t.Context())
projectData := &services.ProjectData{
UUID: uuid.NewString(),
Name: "Test project",
Slug: uuid.NewString(),
Description: "Test project",
}
projectID, err := ctrlProjects.Create(t.Context(), projectData, userID)
assert.NoError(t, err)
assert.NotEmpty(t, projectID)
}

View File

@@ -1,6 +1,6 @@
// Package controllers for token management // Package controllers for token management
// This a token controller, that implements the logic around tokens // This a token controller, that implements the logic around tokens
package controllers package services
import ( import (
"context" "context"

View File

@@ -1,4 +1,4 @@
package controllers_test package services_test
import ( import (
"context" "context"
@@ -6,13 +6,13 @@ import (
"testing" "testing"
"time" "time"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers" "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func newTestTokensController(ctx context.Context) *controllers.TokenController { func newTestTokensController(ctx context.Context) *services.TokenController {
return &controllers.TokenController{ return &services.TokenController{
DB: newTestDBConnection(ctx), DB: newTestDBConnection(ctx),
Redis: newTestRedisConnection(), Redis: newTestRedisConnection(),
} }
@@ -21,14 +21,14 @@ func newTestTokensController(ctx context.Context) *controllers.TokenController {
func TestIntegrationCreateToken_Success(t *testing.T) { func TestIntegrationCreateToken_Success(t *testing.T) {
// Create a user for the token // Create a user for the token
ctrlAccount := newTestAccountController(t.Context()) ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
id, err := ctrlAccount.Create(t.Context(), accountData) id, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: id, UserID: id,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -45,7 +45,7 @@ func TestIntegrationCreateToken_Success(t *testing.T) {
} }
func TestIntegrationCreateToken_UserNotExist(t *testing.T) { func TestIntegrationCreateToken_UserNotExist(t *testing.T) {
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: uuid.NewString(), UserID: uuid.NewString(),
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -58,7 +58,7 @@ func TestIntegrationCreateToken_UserNotExist(t *testing.T) {
tokenVal, tokenID, err := ctrl.Create(t.Context(), tokenData) tokenVal, tokenID, err := ctrl.Create(t.Context(), tokenData)
assert.Error(t, err) assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrUserNotFound) assert.ErrorIs(t, err, services.ErrUserNotFound)
assert.Empty(t, tokenID) assert.Empty(t, tokenID)
assert.Empty(t, tokenVal) assert.Empty(t, tokenVal)
} }
@@ -66,7 +66,7 @@ func TestIntegrationCreateToken_UserNotExist(t *testing.T) {
func TestIntegrationGetToken_Success(t *testing.T) { func TestIntegrationGetToken_Success(t *testing.T) {
// Create a user for the token // Create a user for the token
ctrlAccount := newTestAccountController(t.Context()) ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
@@ -75,7 +75,7 @@ func TestIntegrationGetToken_Success(t *testing.T) {
userID, err := ctrlAccount.Create(t.Context(), accountData) userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: userID, UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -101,7 +101,7 @@ func TestIntegrationGetToken_NotExists(t *testing.T) {
ctrl := newTestTokensController(t.Context()) ctrl := newTestTokensController(t.Context())
token, err := ctrl.Get(t.Context(), uuid.NewString(), uuid.NewString()) token, err := ctrl.Get(t.Context(), uuid.NewString(), uuid.NewString())
assert.Error(t, err) assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrTokenNotFound) assert.ErrorIs(t, err, services.ErrTokenNotFound)
assert.Empty(t, token) assert.Empty(t, token)
} }
@@ -109,14 +109,14 @@ func TestIntegrationGetToken_WrongRequest(t *testing.T) {
ctrl := newTestTokensController(t.Context()) ctrl := newTestTokensController(t.Context())
token, err := ctrl.Get(t.Context(), "test", "test") token, err := ctrl.Get(t.Context(), "test", "test")
assert.Error(t, err) assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrServerError) assert.ErrorIs(t, err, services.ErrServerError)
assert.Empty(t, token) assert.Empty(t, token)
} }
func TestIntegrationVerifyTokenOwner_Success(t *testing.T) { func TestIntegrationVerifyTokenOwner_Success(t *testing.T) {
// Create a user for the token // Create a user for the token
ctrlAccount := newTestAccountController(t.Context()) ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
@@ -124,7 +124,7 @@ func TestIntegrationVerifyTokenOwner_Success(t *testing.T) {
userID, err := ctrlAccount.Create(t.Context(), accountData) userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: userID, UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -142,12 +142,12 @@ func TestIntegrationVerifyTokenOwner_Success(t *testing.T) {
func TestIntegrationVerifyTokenOwner_WrongOwner(t *testing.T) { func TestIntegrationVerifyTokenOwner_WrongOwner(t *testing.T) {
// Create a user for the token // Create a user for the token
ctrlAccount := newTestAccountController(t.Context()) ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
secondAccountData := &controllers.AccountData{ secondAccountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
@@ -157,7 +157,7 @@ func TestIntegrationVerifyTokenOwner_WrongOwner(t *testing.T) {
secondUserID, err := ctrlAccount.Create(t.Context(), secondAccountData) secondUserID, err := ctrlAccount.Create(t.Context(), secondAccountData)
assert.NoError(t, err) assert.NoError(t, err)
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: userID, UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -169,13 +169,13 @@ func TestIntegrationVerifyTokenOwner_WrongOwner(t *testing.T) {
ctrl := newTestTokensController(t.Context()) ctrl := newTestTokensController(t.Context())
_, tokenID, err := ctrl.Create(t.Context(), tokenData) _, tokenID, err := ctrl.Create(t.Context(), tokenData)
assert.NoError(t, err) assert.NoError(t, err)
assert.ErrorIs(t, ctrl.VerifyTokenOwner(t.Context(), secondUserID, tokenID), controllers.ErrUserTokenMismatch) assert.ErrorIs(t, ctrl.VerifyTokenOwner(t.Context(), secondUserID, tokenID), services.ErrUserTokenMismatch)
} }
func TestIntegrationForceExpiration_Success(t *testing.T) { func TestIntegrationForceExpiration_Success(t *testing.T) {
// Create a user for the token // Create a user for the token
ctrlAccount := newTestAccountController(t.Context()) ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
@@ -183,7 +183,7 @@ func TestIntegrationForceExpiration_Success(t *testing.T) {
userID, err := ctrlAccount.Create(t.Context(), accountData) userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: userID, UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -205,7 +205,7 @@ func TestIntegrationForceExpiration_Success(t *testing.T) {
func TestIntegrationRegenerateToken_Success(t *testing.T) { func TestIntegrationRegenerateToken_Success(t *testing.T) {
// Create a user for the token // Create a user for the token
ctrlAccount := newTestAccountController(t.Context()) ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
@@ -214,7 +214,7 @@ func TestIntegrationRegenerateToken_Success(t *testing.T) {
userID, err := ctrlAccount.Create(t.Context(), accountData) userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: userID, UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -255,7 +255,7 @@ func TestIntegrationRegenerateToken_Success(t *testing.T) {
func TestIntegrationListTokens_Success(t *testing.T) { func TestIntegrationListTokens_Success(t *testing.T) {
// Create a user for the token // Create a user for the token
ctrlAccount := newTestAccountController(t.Context()) ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
@@ -263,7 +263,7 @@ func TestIntegrationListTokens_Success(t *testing.T) {
userID, err := ctrlAccount.Create(t.Context(), accountData) userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
tokenDataOne := &controllers.TokenData{ tokenDataOne := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: userID, UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -271,7 +271,7 @@ func TestIntegrationListTokens_Success(t *testing.T) {
"Test": {"test", "test2"}, "Test": {"test", "test2"},
}, },
} }
tokenDataTwo := &controllers.TokenData{ tokenDataTwo := &services.TokenData{
Name: "Test Token again", Name: "Test Token again",
UserID: userID, UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -293,14 +293,14 @@ func TestIntegrationListTokens_Success(t *testing.T) {
func TestIntegrationAuthenticateWithToken_Success(t *testing.T) { func TestIntegrationAuthenticateWithToken_Success(t *testing.T) {
ctrlAccount := newTestAccountController(t.Context()) ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
id, err := ctrlAccount.Create(t.Context(), accountData) id, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: id, UserID: id,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -330,19 +330,19 @@ func TestIntegrationAuthenticateWithToken_UnknownToken(t *testing.T) {
auth, err := ctrl.AuthenticateWithToken(t.Context(), "dummy") auth, err := ctrl.AuthenticateWithToken(t.Context(), "dummy")
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, auth) assert.Nil(t, auth)
assert.ErrorIs(t, err, controllers.ErrTokenNotFound) assert.ErrorIs(t, err, services.ErrTokenNotFound)
} }
func TestIntegrationAuthenticateWithToken_Expired(t *testing.T) { func TestIntegrationAuthenticateWithToken_Expired(t *testing.T) {
ctrlAccount := newTestAccountController(t.Context()) ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
id, err := ctrlAccount.Create(t.Context(), accountData) id, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: id, UserID: id,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -365,19 +365,19 @@ func TestIntegrationAuthenticateWithToken_Expired(t *testing.T) {
auth, err = ctrl.AuthenticateWithToken(t.Context(), tokenVal) auth, err = ctrl.AuthenticateWithToken(t.Context(), tokenVal)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, auth) assert.Nil(t, auth)
assert.ErrorIs(t, err, controllers.ErrBadToken) assert.ErrorIs(t, err, services.ErrBadToken)
} }
func TestIntegrationAuthenticateWithToken_Revoked(t *testing.T) { func TestIntegrationAuthenticateWithToken_Revoked(t *testing.T) {
ctrlAccount := newTestAccountController(t.Context()) ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{ accountData := &services.AccountData{
Password: "qwertyu9", Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"), Email: newTestUniqueEmail("accounts"),
} }
id, err := ctrlAccount.Create(t.Context(), accountData) id, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err) assert.NoError(t, err)
tokenData := &controllers.TokenData{ tokenData := &services.TokenData{
Name: "Test Token", Name: "Test Token",
UserID: id, UserID: id,
ExpiresAt: time.Now().Add(time.Second * 5), ExpiresAt: time.Now().Add(time.Second * 5),
@@ -399,5 +399,5 @@ func TestIntegrationAuthenticateWithToken_Revoked(t *testing.T) {
auth, err = ctrl.AuthenticateWithToken(t.Context(), tokenVal) auth, err = ctrl.AuthenticateWithToken(t.Context(), tokenVal)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, auth) assert.Nil(t, auth)
assert.ErrorIs(t, err, controllers.ErrBadToken) assert.ErrorIs(t, err, services.ErrBadToken)
} }

View File

@@ -0,0 +1,3 @@
ALTER TABLE accounts
DROP COLUMN name,
DROP COLUMN surname;

View File

@@ -0,0 +1,14 @@
-- Up migration (safe for existing data)
ALTER TABLE accounts
ADD COLUMN name TEXT,
ADD COLUMN surname TEXT;
UPDATE accounts
SET
name = 'John',
surname = 'Doe'
WHERE name IS NULL OR surname IS NULL;
ALTER TABLE accounts
ALTER COLUMN name SET NOT NULL,
ALTER COLUMN surname SET NOT NULL;

View File

@@ -0,0 +1 @@
DROP TABLE projects;

View File

@@ -0,0 +1,16 @@
CREATE TABLE projects (
uuid UUID PRIMARY KEY,
name VARCHAR(120) NOT NULL,
slug VARCHAR(120) NOT NULL UNIQUE
CHECK (
slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'
),
description TEXT,
owner_user_id UUID NOT NULL,
closed_at TIMESTAMPTZ NULL,
billing_account_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL,
updated_by UUID NOT NULL
);

View File

@@ -0,0 +1,7 @@
DROP INDEX idx_project_membership_project;
DROP INDEX idx_project_membership_user;
DROP TABLE project_membership;
DROP TYPE membership_status CASCADE;
DROP TYPE project_role CASCADE;

View File

@@ -0,0 +1,35 @@
CREATE TYPE project_role AS ENUM (
'member',
'admin',
'owner'
);
CREATE TYPE membership_status AS ENUM (
'invited',
'active',
'suspended'
);
CREATE TABLE project_membership (
project_uuid UUID NOT NULL
REFERENCES projects(uuid)
ON DELETE CASCADE,
user_uuid UUID NOT NULL
REFERENCES accounts(uuid)
ON DELETE CASCADE,
role project_role NOT NULL,
status membership_status NOT NULL DEFAULT 'active',
invited_by UUID NULL
REFERENCES accounts(uuid),
joined_at TIMESTAMP NULL,
PRIMARY KEY (project_uuid, user_uuid)
);
CREATE INDEX idx_project_membership_user
ON project_membership(user_uuid);
CREATE INDEX idx_project_membership_project
ON project_membership(project_uuid);