Merge pull request 'feature/users' (#4) from feature/users into main
Reviewed-on: #4
This commit is contained in:
commit
9b4353a6f7
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
POSTGRESQL_CONNECTION_STRING=postgres://todo:password@localhost:5432/todo
|
||||||
|
JWT_SECRET_KEY="supersecretjwtkey"
|
79
cmd/main.go
79
cmd/main.go
@ -1,58 +1,105 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
|
||||||
|
authhandler "gitea.michaelthomson.dev/mthomson/habits/internal/auth/handler"
|
||||||
|
authservice "gitea.michaelthomson.dev/mthomson/habits/internal/auth/service"
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/logging"
|
"gitea.michaelthomson.dev/mthomson/habits/internal/logging"
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/middleware"
|
"gitea.michaelthomson.dev/mthomson/habits/internal/middleware"
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/migrate"
|
"gitea.michaelthomson.dev/mthomson/habits/internal/migrate"
|
||||||
todohandler "gitea.michaelthomson.dev/mthomson/habits/internal/todo/handler"
|
todohandler "gitea.michaelthomson.dev/mthomson/habits/internal/todo/handler"
|
||||||
todorepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/postgres"
|
todorepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
|
||||||
todoservice "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
|
todoservice "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
userhandler "gitea.michaelthomson.dev/mthomson/habits/internal/user/handler"
|
||||||
|
userrepository "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
|
||||||
|
userservice "gitea.michaelthomson.dev/mthomson/habits/internal/user/service"
|
||||||
|
pgxuuid "github.com/jackc/pgx-gofrs-uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// create logger
|
// create logger
|
||||||
logger := logging.NewLogger()
|
logger := logging.NewLogger()
|
||||||
|
|
||||||
// create middlewares
|
// load env
|
||||||
contextMiddleware := middleware.ContextMiddleware(logger)
|
err := godotenv.Load()
|
||||||
loggingMiddleware := middleware.LoggingMiddleware(logger)
|
if err != nil {
|
||||||
|
logger.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
stack := []middleware.Middleware{
|
jwtSecretKey := os.Getenv("JWT_SECRET_KEY")
|
||||||
contextMiddleware,
|
postgresqlConnectionString := os.Getenv("POSTGRESQL_CONNECTION_STRING")
|
||||||
|
|
||||||
|
// create hasher instance
|
||||||
|
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
|
||||||
|
|
||||||
|
// create middlewares
|
||||||
|
traceMiddleware := middleware.TraceMiddleware(logger)
|
||||||
|
loggingMiddleware := middleware.LoggingMiddleware(logger)
|
||||||
|
authMiddleware := middleware.AuthMiddleware(logger, []byte(jwtSecretKey))
|
||||||
|
|
||||||
|
unauthenticatedStack := []middleware.Middleware{
|
||||||
|
traceMiddleware,
|
||||||
loggingMiddleware,
|
loggingMiddleware,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authenticatedStack := []middleware.Middleware{
|
||||||
|
traceMiddleware,
|
||||||
|
loggingMiddleware,
|
||||||
|
authMiddleware,
|
||||||
|
}
|
||||||
|
|
||||||
// create db pool
|
// create db pool
|
||||||
postgresUrl := "postgres://todo:password@localhost:5432/todo"
|
dbconfig, err := pgxpool.ParseConfig(postgresqlConnectionString)
|
||||||
db, err := sql.Open("pgx", postgresUrl)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err.Error())
|
logger.Error(err.Error())
|
||||||
os.Exit(1);
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbconfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
|
||||||
|
pgxuuid.Register(conn.TypeMap())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := pgxpool.NewWithConfig(context.Background(), dbconfig)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// run migrations
|
// run migrations
|
||||||
migrate.Migrate(logger, db)
|
migrate.Migrate(logger, db)
|
||||||
|
|
||||||
// create repos
|
// create repos
|
||||||
todoRepository := todorepository.NewPostgresTodoRepository(logger, db)
|
todoRepository := todorepository.NewTodoRepository(logger, db)
|
||||||
|
userRepository := userrepository.NewUserRepository(logger, db)
|
||||||
|
|
||||||
// create services
|
// create services
|
||||||
todoService := todoservice.NewTodoService(logger, todoRepository)
|
todoService := todoservice.NewTodoService(logger, todoRepository)
|
||||||
|
userService := userservice.NewUserService(logger, userRepository, argon2IdHash)
|
||||||
|
authService := authservice.NewAuthService(logger, []byte(jwtSecretKey), userRepository, argon2IdHash)
|
||||||
|
|
||||||
// create mux
|
// create mux
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// register handlers
|
// register handlers
|
||||||
mux.Handle("GET /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoGet(logger, todoService), stack))
|
// auth
|
||||||
mux.Handle("POST /todo", middleware.CompileMiddleware(todohandler.HandleTodoCreate(logger, todoService), stack))
|
mux.Handle("POST /login", middleware.CompileMiddleware(authhandler.HandleLogin(logger, authService), unauthenticatedStack))
|
||||||
mux.Handle("DELETE /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoDelete(logger, todoService), stack))
|
// users
|
||||||
mux.Handle("PUT /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoUpdate(logger, todoService), stack))
|
mux.Handle("POST /register", middleware.CompileMiddleware(userhandler.HandleRegisterUser(logger, userService), unauthenticatedStack))
|
||||||
|
// todos
|
||||||
|
mux.Handle("GET /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoGet(logger, todoService), authenticatedStack))
|
||||||
|
mux.Handle("POST /todo", middleware.CompileMiddleware(todohandler.HandleTodoCreate(logger, todoService), authenticatedStack))
|
||||||
|
mux.Handle("DELETE /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoDelete(logger, todoService), authenticatedStack))
|
||||||
|
mux.Handle("PUT /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoUpdate(logger, todoService), authenticatedStack))
|
||||||
|
|
||||||
// create server
|
// create server
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
|
79
go.mod
79
go.mod
@ -1,65 +1,76 @@
|
|||||||
module gitea.michaelthomson.dev/mthomson/habits
|
module gitea.michaelthomson.dev/mthomson/habits
|
||||||
|
|
||||||
go 1.22.9
|
go 1.23.0
|
||||||
|
|
||||||
|
toolchain go1.23.9
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgx/v5 v5.7.2
|
github.com/gofrs/uuid/v5 v5.3.2
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/testcontainers/testcontainers-go v0.35.0
|
github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0
|
github.com/jackc/pgx/v5 v5.7.4
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/testcontainers/testcontainers-go v0.37.0
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0
|
||||||
|
golang.org/x/crypto v0.38.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.0 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/containerd/containerd v1.7.18 // indirect
|
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/containerd/platforms v0.2.1 // indirect
|
github.com/containerd/platforms v0.2.1 // indirect
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/docker v27.1.1+incompatible // indirect
|
github.com/docker/docker v28.1.1+incompatible // indirect
|
||||||
github.com/docker/go-connections v0.5.0 // indirect
|
github.com/docker/go-connections v0.5.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/ebitengine/purego v0.8.3 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.1 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/compress v1.17.4 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
|
github.com/moby/go-archive v0.1.0 // indirect
|
||||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
github.com/moby/sys/sequential v0.5.0 // indirect
|
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||||
github.com/moby/sys/user v0.1.0 // indirect
|
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||||
github.com/moby/term v0.5.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/morikuni/aec v1.0.0 // indirect
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/shirou/gopsutil/v3 v3.23.12 // indirect
|
github.com/shirou/gopsutil/v4 v4.25.4 // indirect
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/stretchr/testify v1.9.0 // indirect
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
|
||||||
|
google.golang.org/protobuf v1.35.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
204
go.sum
204
go.sum
@ -1,15 +1,13 @@
|
|||||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao=
|
|
||||||
github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4=
|
|
||||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
@ -23,27 +21,32 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
|
github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I=
|
||||||
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc=
|
||||||
|
github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=
|
||||||
|
github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
|
||||||
@ -52,118 +55,121 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
|||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 h1:QWdhlQz98hUe1xmjADOl2mr8ERLrOqj0KWLdkrnNsRQ=
|
||||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2/go.mod h1:Ti7pyNDU/UpXKmBTeFgxTvzYDM9xHLiYKMsLdt4b9cg=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||||
github.com/mdelapenya/tlscert v0.1.0 h1:YTpF579PYUX475eOL+6zyEO3ngLTOUWck78NBuJVXaM=
|
|
||||||
github.com/mdelapenya/tlscert v0.1.0/go.mod h1:wrbyM/DwbFCeCeqdPX/8c6hNOqQgbf0rUDErE1uD+64=
|
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||||
|
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
|
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||||
github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
|
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||||
github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg=
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU=
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
|
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
|
||||||
github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
|
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
|
||||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
|
||||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
|
||||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc=
|
||||||
github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc=
|
||||||
github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4=
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 h1:eEGx9kYzZb2cNhRbBrNOCL/YPOM7+RMJiy3bB+ie0/I=
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0/go.mod h1:hfH71Mia/WWLBgMD2YctYcMlfsbnT0hflweL1dy8Q4s=
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
|
||||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||||
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||||
|
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||||
|
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
|
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||||
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
|
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||||
go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o=
|
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||||
go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A=
|
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
|
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||||
|
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@ -171,17 +177,15 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@ -192,20 +196,20 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
|||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0=
|
google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e h1:Ao9GzfUMPH3zjVfzXG5rlWlk+Q8MXWKwWpwVQE1MXfw=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||||
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
|
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
|
||||||
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
|
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
|
||||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
|
66
internal/auth/handler/login.go
Normal file
66
internal/auth/handler/login.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/auth/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Loginer interface {
|
||||||
|
Login(ctx context.Context, email string, password string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleLogin(logger *slog.Logger, authService Loginer) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
loginRequest := LoginRequest{}
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
err := decoder.Decode(&loginRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(ctx, err.Error())
|
||||||
|
http.Error(w, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := authService.Login(ctx, loginRequest.Email, loginRequest.Password)
|
||||||
|
|
||||||
|
if err == service.ErrUnauthorized {
|
||||||
|
http.Error(w, "", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == service.ErrNotFound {
|
||||||
|
http.Error(w, "", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(ctx, err.Error())
|
||||||
|
http.Error(w, "", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := http.Cookie{
|
||||||
|
Name: "token",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 3600,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &cookie)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
168
internal/auth/handler/login_test.go
Normal file
168
internal/auth/handler/login_test.go
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/auth/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockLoginer struct {
|
||||||
|
LoginFunc func(ctx context.Context, email string, password string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockLoginer) Login(ctx context.Context, email string, password string) (string, error) {
|
||||||
|
return m.LoginFunc(ctx, email, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin(t *testing.T) {
|
||||||
|
logger := slog.Default()
|
||||||
|
t.Run("returns 200 for existing user with correct credentials", func(t *testing.T) {
|
||||||
|
loginRequest := LoginRequest{Email: "test@test.com", Password: "password"}
|
||||||
|
|
||||||
|
token := "examplejwt"
|
||||||
|
|
||||||
|
service := MockLoginer{
|
||||||
|
LoginFunc: func(ctx context.Context, email string, password string) (string, error) {
|
||||||
|
return token, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := HandleLogin(logger, &service)
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(loginRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(res, req)
|
||||||
|
|
||||||
|
AssertStatusCodes(t, res.Code, http.StatusOK)
|
||||||
|
AssertToken(t, res, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 401 for existing user with incorrect credentials", func(t *testing.T) {
|
||||||
|
loginRequest := LoginRequest{Email: "test@test.com", Password: "password"}
|
||||||
|
|
||||||
|
service := MockLoginer{
|
||||||
|
LoginFunc: func(ctx context.Context, email string, password string) (string, error) {
|
||||||
|
return "", service.ErrUnauthorized
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := HandleLogin(logger, &service)
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(loginRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(res, req)
|
||||||
|
|
||||||
|
AssertStatusCodes(t, res.Code, http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 401 for non-existing user", func(t *testing.T) {
|
||||||
|
loginRequest := LoginRequest{Email: "test@test.com", Password: "password"}
|
||||||
|
|
||||||
|
service := MockLoginer{
|
||||||
|
LoginFunc: func(ctx context.Context, email string, password string) (string, error) {
|
||||||
|
return "", service.ErrNotFound
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := HandleLogin(logger, &service)
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(loginRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(res, req)
|
||||||
|
|
||||||
|
AssertStatusCodes(t, res.Code, http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 400 with bad json", func(t *testing.T) {
|
||||||
|
handler := HandleLogin(logger, nil)
|
||||||
|
|
||||||
|
badStruct := struct {
|
||||||
|
Foo string
|
||||||
|
}{
|
||||||
|
Foo: "bar",
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(badStruct)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal request %+v: %v", badStruct, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(res, req)
|
||||||
|
|
||||||
|
AssertStatusCodes(t, res.Code, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 500 arbitrary errors", func(t *testing.T) {
|
||||||
|
loginRequest := LoginRequest{Email: "test@test.com", Password: "password"}
|
||||||
|
|
||||||
|
service := MockLoginer{
|
||||||
|
LoginFunc: func(ctx context.Context, email string, password string) (string, error) {
|
||||||
|
return "", errors.New("foo bar")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := HandleLogin(logger, &service)
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(loginRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(res, req)
|
||||||
|
|
||||||
|
AssertStatusCodes(t, res.Code, http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertStatusCodes(t testing.TB, got, want int) {
|
||||||
|
t.Helper()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got status code: %v, want status code: %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertToken(t testing.TB, res *httptest.ResponseRecorder, want string) {
|
||||||
|
t.Helper()
|
||||||
|
got := res.Result().Cookies()[0].Value
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got cookie: %q, want cookie: %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
70
internal/auth/hashing.go
Normal file
70
internal/auth/hashing.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HashSalt struct {
|
||||||
|
Hash, Salt []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type Argon2IdHash struct {
|
||||||
|
time uint32
|
||||||
|
memory uint32
|
||||||
|
threads uint8
|
||||||
|
keyLen uint32
|
||||||
|
saltLen uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoMatch error = errors.New("hash doesn't match")
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewArgon2IdHash(time, saltLen uint32, memory uint32, threads uint8, keyLen uint32) *Argon2IdHash {
|
||||||
|
return &Argon2IdHash{
|
||||||
|
time: time,
|
||||||
|
saltLen: saltLen,
|
||||||
|
memory: memory,
|
||||||
|
threads: threads,
|
||||||
|
keyLen: keyLen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Argon2IdHash) GenerateHash(password, salt []byte) (*HashSalt, error) {
|
||||||
|
var err error
|
||||||
|
if len(salt) == 0 {
|
||||||
|
salt, err = randomSecret(a.saltLen)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hash := argon2.IDKey(password, salt, a.time, a.memory, a.threads, a.keyLen)
|
||||||
|
return &HashSalt{Hash: hash, Salt: salt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Argon2IdHash) Compare(hash, salt, password []byte) error {
|
||||||
|
hashSalt, err := a.GenerateHash(password, salt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(hash, hashSalt.Hash) {
|
||||||
|
return ErrNoMatch
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomSecret(length uint32) ([]byte, error) {
|
||||||
|
secret := make([]byte, length)
|
||||||
|
|
||||||
|
_, err := rand.Read(secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return secret, nil
|
||||||
|
}
|
90
internal/auth/service/service.go
Normal file
90
internal/auth/service/service.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
|
||||||
|
userrepository "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound error = errors.New("user cannot be found")
|
||||||
|
ErrUnauthorized error = errors.New("user password incorrect")
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepository interface {
|
||||||
|
Create(ctx context.Context, user userrepository.UserRow) (userrepository.UserRow, error)
|
||||||
|
GetByEmail(ctx context.Context, email string) (userrepository.UserRow, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
jwtKey []byte
|
||||||
|
userRepository UserRepository
|
||||||
|
argon2IdHash *auth.Argon2IdHash
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService(logger *slog.Logger, jwtKey []byte, userRepository UserRepository, argon2IdHash *auth.Argon2IdHash) *AuthService {
|
||||||
|
return &AuthService{
|
||||||
|
logger: logger,
|
||||||
|
jwtKey: jwtKey,
|
||||||
|
userRepository: userRepository,
|
||||||
|
argon2IdHash: argon2IdHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AuthService) Login(ctx context.Context, email string, password string) (string, error) {
|
||||||
|
// get user if exists
|
||||||
|
userRow, err := a.userRepository.GetByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
if err == userrepository.ErrNotFound {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
a.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// compare hashed passswords
|
||||||
|
err = a.argon2IdHash.Compare(userRow.HashedPassword, userRow.Salt, []byte(password))
|
||||||
|
|
||||||
|
if err == auth.ErrNoMatch {
|
||||||
|
return "", ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
a.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// create token and return it
|
||||||
|
token, err := a.CreateToken(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a AuthService) CreateToken(ctx context.Context, email string) (string, error) {
|
||||||
|
// Create a new JWT token with claims
|
||||||
|
claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"sub": email,
|
||||||
|
"iss": "todo-app",
|
||||||
|
"aud": "user",
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
tokenString, err := claims.SignedString(a.jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.ErrorContext(ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
81
internal/auth/service/service_test.go
Normal file
81
internal/auth/service/service_test.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package service_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
|
||||||
|
authservice "gitea.michaelthomson.dev/mthomson/habits/internal/auth/service"
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
|
||||||
|
userservice "gitea.michaelthomson.dev/mthomson/habits/internal/user/service"
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
logger := slog.Default()
|
||||||
|
tdb := test.NewTestDatabase(t)
|
||||||
|
defer tdb.TearDown()
|
||||||
|
userRepository := repository.NewUserRepository(logger, tdb.Db)
|
||||||
|
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
|
||||||
|
|
||||||
|
userService := userservice.NewUserService(logger, userRepository, argon2IdHash)
|
||||||
|
authService := authservice.NewAuthService(logger, []byte("secretkey"), userRepository, argon2IdHash)
|
||||||
|
|
||||||
|
_, err := userService.Register(ctx, "test@test.com", "supersecurepassword")
|
||||||
|
AssertNoError(t, err)
|
||||||
|
|
||||||
|
t.Run("login existing user with correct credentials", func(t *testing.T) {
|
||||||
|
want, err := authService.CreateToken(ctx, "test@test.com")
|
||||||
|
AssertNoError(t, err)
|
||||||
|
got, err := authService.Login(ctx, "test@test.com", "supersecurepassword")
|
||||||
|
|
||||||
|
AssertNoError(t, err)
|
||||||
|
AssertTokens(t, got, want)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("login existing user with incorrect credentials", func(t *testing.T) {
|
||||||
|
_, err := authService.Login(ctx, "test@test.com", "superwrongpassword")
|
||||||
|
|
||||||
|
AssertErrors(t, err, authservice.ErrUnauthorized)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("login nonexistant user", func(t *testing.T) {
|
||||||
|
_, err := authService.Login(ctx, "foo@test.com", "supersecurepassword")
|
||||||
|
|
||||||
|
AssertErrors(t, err, authservice.ErrNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertErrors(t testing.TB, got, want error) {
|
||||||
|
t.Helper()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got error: %v, want error: %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertNoError(t testing.TB, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertTokens(t testing.TB, got, want string) {
|
||||||
|
t.Helper()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("expected matching tokens, got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUUID(t testing.TB) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
uuid, err := uuid.NewV4()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error generation uuid: %v", err)
|
||||||
|
}
|
||||||
|
return uuid
|
||||||
|
}
|
@ -20,7 +20,7 @@ func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewLogger() *slog.Logger {
|
func NewLogger() *slog.Logger {
|
||||||
baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: false})
|
baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})
|
||||||
customHandler := &ContextHandler{Handler: baseHandler}
|
customHandler := &ContextHandler{Handler: baseHandler}
|
||||||
logger := slog.New(customHandler)
|
logger := slog.New(customHandler)
|
||||||
|
|
||||||
|
60
internal/middleware/auth.go
Normal file
60
internal/middleware/auth.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const EmailKey contextKey = "email"
|
||||||
|
|
||||||
|
func AuthMiddleware(logger *slog.Logger, jwtKey []byte) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("token")
|
||||||
|
if err == http.ErrNoCookie {
|
||||||
|
logger.WarnContext(r.Context(), "token not provided")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(r.Context(), err.Error())
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := cookie.Value
|
||||||
|
|
||||||
|
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
|
||||||
|
return jwtKey, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if !token.Valid {
|
||||||
|
logger.WarnContext(r.Context(), err.Error())
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(r.Context(), err.Error())
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email, err := token.Claims.GetSubject()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(r.Context(), err.Error())
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), EmailKey, email)
|
||||||
|
newReq := r.WithContext(ctx)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, newReq)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -5,17 +5,19 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/gofrs/uuid/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
|
||||||
const TraceIdKey contextKey = "trace_id"
|
const TraceIdKey contextKey = "trace_id"
|
||||||
|
|
||||||
func ContextMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
func TraceMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
traceid := uuid.NewString()
|
traceid, err := uuid.NewV4()
|
||||||
ctx := context.WithValue(r.Context(), TraceIdKey, traceid)
|
if err != nil {
|
||||||
|
logger.ErrorContext(r.Context(), err.Error())
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), TraceIdKey, traceid.String())
|
||||||
newReq := r.WithContext(ctx)
|
newReq := r.WithContext(ctx)
|
||||||
|
|
||||||
next.ServeHTTP(w, newReq)
|
next.ServeHTTP(w, newReq)
|
@ -4,6 +4,8 @@ import "net/http"
|
|||||||
|
|
||||||
type Middleware func(http.Handler) http.Handler
|
type Middleware func(http.Handler) http.Handler
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
func CompileMiddleware(h http.Handler, m []Middleware) http.Handler {
|
func CompileMiddleware(h http.Handler, m []Middleware) http.Handler {
|
||||||
if len(m) < 1 {
|
if len(m) < 1 {
|
||||||
return h
|
return h
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
package migrate
|
package migrate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
@ -18,7 +19,7 @@ type Migration struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Migrate(logger *slog.Logger, db *sql.DB) {
|
func Migrate(logger *slog.Logger, db *pgxpool.Pool) {
|
||||||
logger.Info("Running migrations...")
|
logger.Info("Running migrations...")
|
||||||
migrationTableSql := `
|
migrationTableSql := `
|
||||||
CREATE TABLE IF NOT EXISTS migrations(
|
CREATE TABLE IF NOT EXISTS migrations(
|
||||||
@ -26,7 +27,7 @@ func Migrate(logger *slog.Logger, db *sql.DB) {
|
|||||||
name VARCHAR(50)
|
name VARCHAR(50)
|
||||||
);`
|
);`
|
||||||
|
|
||||||
_, err := db.Exec(migrationTableSql)
|
_, err := db.Exec(context.Background(), migrationTableSql)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -38,21 +39,21 @@ func Migrate(logger *slog.Logger, db *sql.DB) {
|
|||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
var migration Migration
|
var migration Migration
|
||||||
row := db.QueryRow("SELECT * FROM migrations WHERE name = $1;", file.Name())
|
row := db.QueryRow(context.Background(), "SELECT * FROM migrations WHERE name = $1;", file.Name())
|
||||||
err = row.Scan(&migration.Version, &migration.Name)
|
err = row.Scan(&migration.Version, &migration.Name)
|
||||||
if err == sql.ErrNoRows {
|
if err == pgx.ErrNoRows {
|
||||||
logger.Info(fmt.Sprintf("Running migration: %s", file.Name()))
|
logger.Info(fmt.Sprintf("Running migration: %s", file.Name()))
|
||||||
migrationSql, err := migrations.ReadFile(fmt.Sprintf("migrations/%s", file.Name()))
|
migrationSql, err := migrations.ReadFile(fmt.Sprintf("migrations/%s", file.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.Exec(string(migrationSql))
|
_, err = db.Exec(context.Background(), string(migrationSql))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.Exec("INSERT INTO migrations(name) VALUES($1);", file.Name())
|
_, err = db.Exec(context.Background(), "INSERT INTO migrations(name) VALUES($1);", file.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -3,3 +3,10 @@ CREATE TABLE todo(
|
|||||||
name VARCHAR(50),
|
name VARCHAR(50),
|
||||||
done BOOLEAN
|
done BOOLEAN
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users(
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email VARCHAR NOT NULL UNIQUE,
|
||||||
|
hashed_password bytea NOT NULL,
|
||||||
|
salt bytea NOT NULL
|
||||||
|
);
|
||||||
|
@ -2,19 +2,21 @@ package test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/migrate"
|
"gitea.michaelthomson.dev/mthomson/habits/internal/migrate"
|
||||||
|
pgxuuid "github.com/jackc/pgx-gofrs-uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/testcontainers/testcontainers-go"
|
"github.com/testcontainers/testcontainers-go"
|
||||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||||
"github.com/testcontainers/testcontainers-go/wait"
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TestDatabase struct {
|
type TestDatabase struct {
|
||||||
Db *sql.DB
|
Db *pgxpool.Pool
|
||||||
container testcontainers.Container
|
container testcontainers.Container
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,8 +44,18 @@ func NewTestDatabase(tb testing.TB) *TestDatabase {
|
|||||||
tb.Fatalf("Failed to get connection string: %v", err)
|
tb.Fatalf("Failed to get connection string: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dbconfig, err := pgxpool.ParseConfig(connectionString)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("Failed to create db config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbconfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
|
||||||
|
pgxuuid.Register(conn.TypeMap())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// create db pool
|
// create db pool
|
||||||
db, err := sql.Open("pgx", connectionString)
|
db, err := pgxpool.NewWithConfig(context.Background(), dbconfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tb.Fatalf("Failed to open db pool: %v", err)
|
tb.Fatalf("Failed to open db pool: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -1,59 +0,0 @@
|
|||||||
package inmemory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
type InMemoryTodoRepository struct {
|
|
||||||
Db map[int64]repository.TodoRow
|
|
||||||
id int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewInMemoryTodoRepository() InMemoryTodoRepository {
|
|
||||||
return InMemoryTodoRepository{
|
|
||||||
Db: make(map[int64]repository.TodoRow),
|
|
||||||
id: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *InMemoryTodoRepository) GetById(id int64) (repository.TodoRow, error) {
|
|
||||||
todo, found := r.Db[id]
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
return todo, repository.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return todo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *InMemoryTodoRepository) Create(todo repository.TodoRow) (repository.TodoRow, error) {
|
|
||||||
todo.Id = r.id
|
|
||||||
r.Db[r.id] = todo
|
|
||||||
r.id++
|
|
||||||
|
|
||||||
return todo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *InMemoryTodoRepository) Update(todo repository.TodoRow) error {
|
|
||||||
_, found := r.Db[todo.Id]
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
return repository.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Db[todo.Id] = todo
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *InMemoryTodoRepository) Delete(id int64) error {
|
|
||||||
_, found := r.Db[id]
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
return repository.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(r.Db, id)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -1,94 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PostgresTodoRepository struct {
|
|
||||||
logger *slog.Logger
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPostgresTodoRepository(logger *slog.Logger, db *sql.DB) *PostgresTodoRepository {
|
|
||||||
return &PostgresTodoRepository{
|
|
||||||
logger: logger,
|
|
||||||
db: db,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *PostgresTodoRepository) GetById(ctx context.Context, id int64) (repository.TodoRow, error) {
|
|
||||||
todo := repository.TodoRow{}
|
|
||||||
|
|
||||||
err := r.db.QueryRow("SELECT * FROM todo WHERE id = $1;", id).Scan(&todo.Id, &todo.Name, &todo.Done)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return todo, repository.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logger.ErrorContext(ctx, err.Error())
|
|
||||||
return todo, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return todo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *PostgresTodoRepository) Create(ctx context.Context, todo repository.TodoRow) (repository.TodoRow, error) {
|
|
||||||
result := r.db.QueryRow("INSERT INTO todo (name, done) VALUES ($1, $2) RETURNING id;", todo.Name, todo.Done)
|
|
||||||
err := result.Scan(&todo.Id)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
r.logger.ErrorContext(ctx, err.Error())
|
|
||||||
return repository.TodoRow{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return todo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *PostgresTodoRepository) Update(ctx context.Context, todo repository.TodoRow) error {
|
|
||||||
result, err := r.db.Exec("UPDATE todo SET name = $1, done = $2 WHERE id = $3;", todo.Name, todo.Done, todo.Id)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
r.logger.ErrorContext(ctx, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsAffected, err := result.RowsAffected()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
r.logger.ErrorContext(ctx, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowsAffected == 0 {
|
|
||||||
return repository.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *PostgresTodoRepository) Delete(ctx context.Context, id int64) error {
|
|
||||||
result, err := r.db.Exec("DELETE FROM todo WHERE id = $1;", id)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
r.logger.ErrorContext(ctx, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsAffected, err := result.RowsAffected()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
r.logger.ErrorContext(ctx, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowsAffected == 0 {
|
|
||||||
return repository.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -1,7 +1,12 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -21,3 +26,78 @@ func NewTodoRow(name string, done bool) TodoRow {
|
|||||||
func (t TodoRow) Equal(todo TodoRow) bool {
|
func (t TodoRow) Equal(todo TodoRow) bool {
|
||||||
return t.Id == todo.Id && t.Name == todo.Name && t.Done == todo.Done
|
return t.Id == todo.Id && t.Name == todo.Name && t.Done == todo.Done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TodoRepository struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTodoRepository(logger *slog.Logger, db *pgxpool.Pool) *TodoRepository {
|
||||||
|
return &TodoRepository{
|
||||||
|
logger: logger,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TodoRepository) GetById(ctx context.Context, id int64) (TodoRow, error) {
|
||||||
|
todo := TodoRow{}
|
||||||
|
|
||||||
|
err := r.db.QueryRow(ctx, "SELECT * FROM todo WHERE id = $1;", id).Scan(&todo.Id, &todo.Name, &todo.Done)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return todo, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return todo, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return todo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TodoRepository) Create(ctx context.Context, todo TodoRow) (TodoRow, error) {
|
||||||
|
result := r.db.QueryRow(ctx, "INSERT INTO todo (name, done) VALUES ($1, $2) RETURNING id;", todo.Name, todo.Done)
|
||||||
|
err := result.Scan(&todo.Id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return TodoRow{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return todo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TodoRepository) Update(ctx context.Context, todo TodoRow) error {
|
||||||
|
result, err := r.db.Exec(ctx, "UPDATE todo SET name = $1, done = $2 WHERE id = $3;", todo.Name, todo.Done, todo.Id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected := result.RowsAffected()
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TodoRepository) Delete(ctx context.Context, id int64) error {
|
||||||
|
result, err := r.db.Exec(ctx, "DELETE FROM todo WHERE id = $1;", id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected := result.RowsAffected()
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package postgres
|
package repository_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
|
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
|
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCRUD(t *testing.T) {
|
func TestCRUD(t *testing.T) {
|
||||||
@ -16,7 +15,7 @@ func TestCRUD(t *testing.T) {
|
|||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
tdb := test.NewTestDatabase(t)
|
tdb := test.NewTestDatabase(t)
|
||||||
defer tdb.TearDown()
|
defer tdb.TearDown()
|
||||||
r := NewPostgresTodoRepository(logger, tdb.Db)
|
r := repository.NewTodoRepository(logger, tdb.Db)
|
||||||
|
|
||||||
t.Run("creates new todo", func(t *testing.T) {
|
t.Run("creates new todo", func(t *testing.T) {
|
||||||
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
|
want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
|
@ -1,92 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SqliteTodoRepository struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSqliteTodoRepository(db *sql.DB) *SqliteTodoRepository {
|
|
||||||
return &SqliteTodoRepository{
|
|
||||||
db: db,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SqliteTodoRepository) GetById(id int64) (repository.TodoRow, error) {
|
|
||||||
todo := repository.TodoRow{}
|
|
||||||
|
|
||||||
row := r.db.QueryRow("SELECT * FROM todo WHERE id = ?;", id)
|
|
||||||
err := row.Scan(&todo.Id, &todo.Name, &todo.Done)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return todo, repository.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return todo, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return todo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SqliteTodoRepository) Create(todo repository.TodoRow) (repository.TodoRow, error) {
|
|
||||||
result, err := r.db.Exec("INSERT INTO todo (name, done) VALUES (?, ?)", todo.Name, todo.Done)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return repository.TodoRow{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := result.LastInsertId()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return repository.TodoRow{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
todo.Id = id
|
|
||||||
|
|
||||||
return todo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SqliteTodoRepository) Update(todo repository.TodoRow) error {
|
|
||||||
result, err := r.db.Exec("UPDATE todo SET name = ?, done = ? WHERE id = ?", todo.Name, todo.Done, todo.Id)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsAffected, err := result.RowsAffected()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowsAffected == 0 {
|
|
||||||
return repository.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SqliteTodoRepository) Delete(id int64) error {
|
|
||||||
result, err := r.db.Exec("DELETE FROM todo WHERE id = ?", id)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsAffected, err := result.RowsAffected()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowsAffected == 0 {
|
|
||||||
return repository.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -7,9 +7,7 @@ import (
|
|||||||
|
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
|
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
|
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository"
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/postgres"
|
|
||||||
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
|
"gitea.michaelthomson.dev/mthomson/habits/internal/todo/service"
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateTodo(t *testing.T) {
|
func TestCreateTodo(t *testing.T) {
|
||||||
@ -18,7 +16,7 @@ func TestCreateTodo(t *testing.T) {
|
|||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
tdb := test.NewTestDatabase(t)
|
tdb := test.NewTestDatabase(t)
|
||||||
defer tdb.TearDown()
|
defer tdb.TearDown()
|
||||||
r := postgres.NewPostgresTodoRepository(logger, tdb.Db)
|
r := repository.NewTodoRepository(logger, tdb.Db)
|
||||||
|
|
||||||
todoService := service.NewTodoService(logger, r)
|
todoService := service.NewTodoService(logger, r)
|
||||||
|
|
||||||
@ -37,11 +35,11 @@ func TestGetTodo(t *testing.T) {
|
|||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
tdb := test.NewTestDatabase(t)
|
tdb := test.NewTestDatabase(t)
|
||||||
defer tdb.TearDown()
|
defer tdb.TearDown()
|
||||||
r := postgres.NewPostgresTodoRepository(logger, tdb.Db)
|
r := repository.NewTodoRepository(logger, tdb.Db)
|
||||||
|
|
||||||
row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
|
row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
|
||||||
_, err := r.Create(ctx, row)
|
_, err := r.Create(ctx, row)
|
||||||
AssertNoError(t, err);
|
AssertNoError(t, err)
|
||||||
|
|
||||||
todoService := service.NewTodoService(logger, r)
|
todoService := service.NewTodoService(logger, r)
|
||||||
|
|
||||||
@ -64,11 +62,11 @@ func TestDeleteTodo(t *testing.T) {
|
|||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
tdb := test.NewTestDatabase(t)
|
tdb := test.NewTestDatabase(t)
|
||||||
defer tdb.TearDown()
|
defer tdb.TearDown()
|
||||||
r := postgres.NewPostgresTodoRepository(logger, tdb.Db)
|
r := repository.NewTodoRepository(logger, tdb.Db)
|
||||||
|
|
||||||
row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
|
row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
|
||||||
_, err := r.Create(ctx, row)
|
_, err := r.Create(ctx, row)
|
||||||
AssertNoError(t, err);
|
AssertNoError(t, err)
|
||||||
|
|
||||||
todoService := service.NewTodoService(logger, r)
|
todoService := service.NewTodoService(logger, r)
|
||||||
|
|
||||||
@ -91,11 +89,11 @@ func TestUpdateTodo(t *testing.T) {
|
|||||||
logger := slog.Default()
|
logger := slog.Default()
|
||||||
tdb := test.NewTestDatabase(t)
|
tdb := test.NewTestDatabase(t)
|
||||||
defer tdb.TearDown()
|
defer tdb.TearDown()
|
||||||
r := postgres.NewPostgresTodoRepository(logger, tdb.Db)
|
r := repository.NewTodoRepository(logger, tdb.Db)
|
||||||
|
|
||||||
row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
|
row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false}
|
||||||
_, err := r.Create(ctx, row)
|
_, err := r.Create(ctx, row)
|
||||||
AssertNoError(t, err);
|
AssertNoError(t, err)
|
||||||
|
|
||||||
todoService := service.NewTodoService(logger, r)
|
todoService := service.NewTodoService(logger, r)
|
||||||
|
|
||||||
|
67
internal/user/handler/register.go
Normal file
67
internal/user/handler/register.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/user/service"
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRegisterer interface {
|
||||||
|
Register(ctx context.Context, email string, password string) (uuid.UUID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterResponse struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleRegisterUser(logger *slog.Logger, userService UserRegisterer) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
registerRequest := RegisterRequest{}
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
err := decoder.Decode(®isterRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(ctx, err.Error())
|
||||||
|
http.Error(w, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid, err := userService.Register(ctx, registerRequest.Email, registerRequest.Password)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == service.ErrUserExists {
|
||||||
|
http.Error(w, "", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.ErrorContext(ctx, err.Error())
|
||||||
|
http.Error(w, "", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := RegisterResponse{uuid.String()}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
err = json.NewEncoder(w).Encode(response)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(ctx, err.Error())
|
||||||
|
http.Error(w, "", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
148
internal/user/handler/register_test.go
Normal file
148
internal/user/handler/register_test.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/user/service"
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockUserRegisterer struct {
|
||||||
|
RegisterUserFunc func(ctx context.Context, email string, password string) (uuid.UUID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRegisterer) Register(ctx context.Context, email string, password string) (uuid.UUID, error) {
|
||||||
|
return m.RegisterUserFunc(ctx, email, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUser(t *testing.T) {
|
||||||
|
logger := slog.Default()
|
||||||
|
t.Run("create user", func(t *testing.T) {
|
||||||
|
createUserRequest := RegisterRequest{Email: "test@test.com", Password: "password"}
|
||||||
|
|
||||||
|
newUUID := NewUUID(t)
|
||||||
|
|
||||||
|
service := MockUserRegisterer{
|
||||||
|
RegisterUserFunc: func(ctx context.Context, email string, password string) (uuid.UUID, error) {
|
||||||
|
return newUUID, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := HandleRegisterUser(logger, &service)
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(createUserRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal request %+v: %v", createUserRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBuffer(requestBody))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(res, req)
|
||||||
|
|
||||||
|
AssertStatusCodes(t, res.Code, http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 409 when user exists", func(t *testing.T) {
|
||||||
|
createUserRequest := RegisterRequest{Email: "test@test.com", Password: "password"}
|
||||||
|
|
||||||
|
newUUID := NewUUID(t)
|
||||||
|
|
||||||
|
service := MockUserRegisterer{
|
||||||
|
RegisterUserFunc: func(ctx context.Context, email string, password string) (uuid.UUID, error) {
|
||||||
|
return newUUID, service.ErrUserExists
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := HandleRegisterUser(logger, &service)
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(createUserRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal request %+v: %v", createUserRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBuffer(requestBody))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(res, req)
|
||||||
|
|
||||||
|
AssertStatusCodes(t, res.Code, http.StatusConflict)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 400 with bad json", func(t *testing.T) {
|
||||||
|
handler := HandleRegisterUser(logger, nil)
|
||||||
|
|
||||||
|
badStruct := struct {
|
||||||
|
Foo string
|
||||||
|
}{
|
||||||
|
Foo: "bar",
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(badStruct)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal request %+v: %v", badStruct, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBuffer(requestBody))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(res, req)
|
||||||
|
|
||||||
|
AssertStatusCodes(t, res.Code, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 500 arbitrary errors", func(t *testing.T) {
|
||||||
|
createUserRequest := RegisterRequest{Email: "test@test.com", Password: "password"}
|
||||||
|
|
||||||
|
newUUID := NewUUID(t)
|
||||||
|
|
||||||
|
service := MockUserRegisterer{
|
||||||
|
RegisterUserFunc: func(ctx context.Context, email string, password string) (uuid.UUID, error) {
|
||||||
|
return newUUID, errors.New("foo bar")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := HandleRegisterUser(logger, &service)
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(createUserRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal request %+v: %v", createUserRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/user", bytes.NewBuffer(requestBody))
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler(res, req)
|
||||||
|
|
||||||
|
AssertStatusCodes(t, res.Code, http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertStatusCodes(t testing.TB, got, want int) {
|
||||||
|
t.Helper()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got status code: %v, want status code: %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUUID(t testing.TB) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
uuid, err := uuid.NewV4()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error generation uuid: %v", err)
|
||||||
|
}
|
||||||
|
return uuid
|
||||||
|
}
|
130
internal/user/repository/repository.go
Normal file
130
internal/user/repository/repository.go
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound error = errors.New("user cannot be found")
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRow struct {
|
||||||
|
Id uuid.UUID
|
||||||
|
Email string
|
||||||
|
HashedPassword []byte
|
||||||
|
Salt []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u UserRow) Equal(user UserRow) bool {
|
||||||
|
return u.Id == user.Id && u.Email == user.Email && bytes.Equal(u.HashedPassword, user.HashedPassword) && bytes.Equal(u.Salt, user.Salt)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository(logger *slog.Logger, db *pgxpool.Pool) *UserRepository {
|
||||||
|
return &UserRepository{
|
||||||
|
logger: logger,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetById(ctx context.Context, id uuid.UUID) (UserRow, error) {
|
||||||
|
user := UserRow{}
|
||||||
|
|
||||||
|
err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1;", id).Scan(&user.Id, &user.Email, &user.HashedPassword, &user.Salt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return user, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (UserRow, error) {
|
||||||
|
user := UserRow{}
|
||||||
|
|
||||||
|
err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE email = $1;", email).Scan(&user.Id, &user.Email, &user.HashedPassword, &user.Salt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return user, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Create(ctx context.Context, user UserRow) (UserRow, error) {
|
||||||
|
var result pgx.Row
|
||||||
|
if user.Id.IsNil() {
|
||||||
|
result = r.db.QueryRow(ctx, "INSERT INTO users (email, hashed_password, salt) VALUES ($1, $2, $3) RETURNING id;", user.Email, user.HashedPassword, user.Salt)
|
||||||
|
|
||||||
|
err := result.Scan(&user.Id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return UserRow{}, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err := r.db.Exec(ctx, "INSERT INTO users (id, email, hashed_password, salt) VALUES ($1, $2, $3, $4);", user.Id, user.Email, user.HashedPassword, user.Salt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return UserRow{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Update(ctx context.Context, user UserRow) error {
|
||||||
|
result, err := r.db.Exec(ctx, "UPDATE users SET email = $1, hashed_password = $2, salt = $3 WHERE id = $4;", user.Email, user.HashedPassword, user.Salt, user.Id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected := result.RowsAffected()
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
result, err := r.db.Exec(ctx, "DELETE FROM users WHERE id = $1;", id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected := result.RowsAffected()
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
99
internal/user/repository/repository_test.go
Normal file
99
internal/user/repository/repository_test.go
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
package repository_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCRUD(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
logger := slog.Default()
|
||||||
|
tdb := test.NewTestDatabase(t)
|
||||||
|
defer tdb.TearDown()
|
||||||
|
r := repository.NewUserRepository(logger, tdb.Db)
|
||||||
|
uuid := NewUUID(t)
|
||||||
|
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
|
||||||
|
|
||||||
|
hashSalt, err := argon2IdHash.GenerateHash([]byte("supersecurepassword"), []byte("supersecuresalt"))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not generate hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("creates new user", func(t *testing.T) {
|
||||||
|
newUser := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt}
|
||||||
|
_, err := r.Create(ctx, newUser)
|
||||||
|
AssertNoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("gets user by id", func(t *testing.T) {
|
||||||
|
want := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt}
|
||||||
|
got, err := r.GetById(ctx, uuid)
|
||||||
|
AssertNoError(t, err)
|
||||||
|
AssertUserRows(t, got, want)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("gets user by email", func(t *testing.T) {
|
||||||
|
want := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt}
|
||||||
|
got, err := r.GetByEmail(ctx, "test@test.com")
|
||||||
|
AssertNoError(t, err)
|
||||||
|
AssertUserRows(t, got, want)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("updates user", func(t *testing.T) {
|
||||||
|
want := repository.UserRow{Id: uuid, Email: "new@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt}
|
||||||
|
err := r.Update(ctx, want)
|
||||||
|
AssertNoError(t, err)
|
||||||
|
|
||||||
|
got, err := r.GetById(ctx, uuid)
|
||||||
|
AssertNoError(t, err)
|
||||||
|
AssertUserRows(t, got, want)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deletes user", func(t *testing.T) {
|
||||||
|
err := r.Delete(ctx, uuid)
|
||||||
|
AssertNoError(t, err)
|
||||||
|
|
||||||
|
want := repository.ErrNotFound
|
||||||
|
_, got := r.GetById(ctx, uuid)
|
||||||
|
|
||||||
|
AssertErrors(t, got, want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertErrors(t testing.TB, got, want error) {
|
||||||
|
t.Helper()
|
||||||
|
if !errors.Is(got, want) {
|
||||||
|
t.Errorf("got error: %v, want error: %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertNoError(t testing.TB, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertUserRows(t testing.TB, got, want repository.UserRow) {
|
||||||
|
t.Helper()
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("got %+v want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUUID(t testing.TB) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
uuid, err := uuid.NewV4()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error generation uuid: %v", err)
|
||||||
|
}
|
||||||
|
return uuid
|
||||||
|
}
|
185
internal/user/service/service.go
Normal file
185
internal/user/service/service.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound error = errors.New("user cannot be found")
|
||||||
|
ErrUserExists error = errors.New("user already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Id uuid.UUID
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserFromUserRow(userRow repository.UserRow) User {
|
||||||
|
return User{Id: userRow.Id, Email: userRow.Email}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t User) Equal(user User) bool {
|
||||||
|
return t.Id == user.Id && t.Email == user.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserRepository interface {
|
||||||
|
Create(ctx context.Context, user repository.UserRow) (repository.UserRow, error)
|
||||||
|
GetById(ctx context.Context, id uuid.UUID) (repository.UserRow, error)
|
||||||
|
GetByEmail(ctx context.Context, email string) (repository.UserRow, error)
|
||||||
|
Update(ctx context.Context, user repository.UserRow) error
|
||||||
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserService struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
repo UserRepository
|
||||||
|
argon2IdHash *auth.Argon2IdHash
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(logger *slog.Logger, userRepo UserRepository, argon2IdHash *auth.Argon2IdHash) *UserService {
|
||||||
|
return &UserService{
|
||||||
|
logger: logger,
|
||||||
|
repo: userRepo,
|
||||||
|
argon2IdHash: argon2IdHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) GetUser(ctx context.Context, id uuid.UUID) (User, error) {
|
||||||
|
user, err := s.repo.GetById(ctx, id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == repository.ErrNotFound {
|
||||||
|
return User{}, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserFromUserRow(user), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) Register(ctx context.Context, email string, password string) (uuid.UUID, error) {
|
||||||
|
uuid, err := uuid.NewV4()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return uuid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.repo.GetByEmail(ctx, email)
|
||||||
|
|
||||||
|
if err != repository.ErrNotFound {
|
||||||
|
return uuid, ErrUserExists
|
||||||
|
}
|
||||||
|
|
||||||
|
hashSalt, err := s.argon2IdHash.GenerateHash([]byte(password), nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return uuid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userRow := repository.UserRow{Id: uuid, Email: email, HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt}
|
||||||
|
|
||||||
|
_, err = s.repo.Create(ctx, userRow)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return uuid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||||
|
err := s.repo.Delete(ctx, id)
|
||||||
|
|
||||||
|
if err == repository.ErrNotFound {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) UpdateUserEmail(ctx context.Context, id uuid.UUID, email string) error {
|
||||||
|
user, err := s.repo.GetById(ctx, id)
|
||||||
|
|
||||||
|
if err == repository.ErrNotFound {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.repo.GetByEmail(ctx, email)
|
||||||
|
|
||||||
|
switch err {
|
||||||
|
case repository.ErrNotFound:
|
||||||
|
user.Email = email
|
||||||
|
|
||||||
|
err = s.repo.Update(ctx, user)
|
||||||
|
|
||||||
|
if err == repository.ErrNotFound {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
case nil:
|
||||||
|
return ErrUserExists
|
||||||
|
default:
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) UpdateUserPassword(ctx context.Context, id uuid.UUID, password string) error {
|
||||||
|
user, err := s.repo.GetById(ctx, id)
|
||||||
|
|
||||||
|
if err == repository.ErrNotFound {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hashSalt, err := s.argon2IdHash.GenerateHash([]byte(password), nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.HashedPassword = hashSalt.Hash
|
||||||
|
user.Salt = hashSalt.Salt
|
||||||
|
|
||||||
|
err = s.repo.Update(ctx, user)
|
||||||
|
|
||||||
|
if err == repository.ErrNotFound {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorContext(ctx, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
149
internal/user/service/service_test.go
Normal file
149
internal/user/service/service_test.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package service_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/auth"
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/test"
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/user/repository"
|
||||||
|
"gitea.michaelthomson.dev/mthomson/habits/internal/user/service"
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegisterUser(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
logger := slog.Default()
|
||||||
|
tdb := test.NewTestDatabase(t)
|
||||||
|
defer tdb.TearDown()
|
||||||
|
r := repository.NewUserRepository(logger, tdb.Db)
|
||||||
|
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
|
||||||
|
|
||||||
|
userService := service.NewUserService(logger, r, argon2IdHash)
|
||||||
|
|
||||||
|
t.Run("Create user", func(t *testing.T) {
|
||||||
|
_, err := userService.Register(ctx, "test@test.com", "supersecurepassword")
|
||||||
|
|
||||||
|
AssertNoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUser(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
logger := slog.Default()
|
||||||
|
tdb := test.NewTestDatabase(t)
|
||||||
|
defer tdb.TearDown()
|
||||||
|
r := repository.NewUserRepository(logger, tdb.Db)
|
||||||
|
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
|
||||||
|
|
||||||
|
userService := service.NewUserService(logger, r, argon2IdHash)
|
||||||
|
|
||||||
|
uuid, err := userService.Register(ctx, "test@test.com", "supersecurepassword")
|
||||||
|
AssertNoError(t, err)
|
||||||
|
|
||||||
|
t.Run("Get exisiting user", func(t *testing.T) {
|
||||||
|
_, err := userService.GetUser(ctx, uuid)
|
||||||
|
|
||||||
|
AssertNoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Get non-existant user", func(t *testing.T) {
|
||||||
|
_, err := userService.GetUser(ctx, NewUUID(t))
|
||||||
|
|
||||||
|
AssertErrors(t, err, service.ErrNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteUser(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
logger := slog.Default()
|
||||||
|
tdb := test.NewTestDatabase(t)
|
||||||
|
defer tdb.TearDown()
|
||||||
|
r := repository.NewUserRepository(logger, tdb.Db)
|
||||||
|
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
|
||||||
|
|
||||||
|
userService := service.NewUserService(logger, r, argon2IdHash)
|
||||||
|
|
||||||
|
uuid, err := userService.Register(ctx, "test@test.com", "supersecurepassword")
|
||||||
|
AssertNoError(t, err)
|
||||||
|
|
||||||
|
t.Run("Delete exisiting user", func(t *testing.T) {
|
||||||
|
err := userService.DeleteUser(ctx, uuid)
|
||||||
|
|
||||||
|
AssertNoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Delete non-existant user", func(t *testing.T) {
|
||||||
|
err := userService.DeleteUser(ctx, uuid)
|
||||||
|
|
||||||
|
AssertErrors(t, err, service.ErrNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateUserEmail(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
logger := slog.Default()
|
||||||
|
tdb := test.NewTestDatabase(t)
|
||||||
|
defer tdb.TearDown()
|
||||||
|
r := repository.NewUserRepository(logger, tdb.Db)
|
||||||
|
argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256)
|
||||||
|
|
||||||
|
userService := service.NewUserService(logger, r, argon2IdHash)
|
||||||
|
|
||||||
|
uuid, err := userService.Register(ctx, "test@test.com", "supersecurepassword")
|
||||||
|
AssertNoError(t, err)
|
||||||
|
|
||||||
|
t.Run("Update existing user email", func(t *testing.T) {
|
||||||
|
err := userService.UpdateUserEmail(ctx, uuid, "newemail@test.com")
|
||||||
|
|
||||||
|
AssertNoError(t, err)
|
||||||
|
|
||||||
|
newUser, err := userService.GetUser(ctx, uuid)
|
||||||
|
|
||||||
|
AssertNoError(t, err)
|
||||||
|
|
||||||
|
if newUser.Email != "newemail@test.com" {
|
||||||
|
t.Errorf("Emails do not match wanted %q, got %q", "newemail@test.com", newUser.Email)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Update non-existant user", func(t *testing.T) {
|
||||||
|
err := userService.UpdateUserEmail(ctx, NewUUID(t), "newemail@test.com")
|
||||||
|
|
||||||
|
AssertErrors(t, err, service.ErrNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertErrors(t testing.TB, got, want error) {
|
||||||
|
t.Helper()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got error: %v, want error: %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertNoError(t testing.TB, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertUsers(t testing.TB, got, want service.User) {
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Errorf("got %+v want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUUID(t testing.TB) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
uuid, err := uuid.NewV4()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error generation uuid: %v", err)
|
||||||
|
}
|
||||||
|
return uuid
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user