From f1bbd06ef7cfdc9cf38d3ba007d42e29d43d282b Mon Sep 17 00:00:00 2001 From: Michael Thomson Date: Thu, 15 May 2025 14:47:46 -0400 Subject: [PATCH 1/9] sql driver to pgx driver --- cmd/main.go | 7 +- internal/migrate/migrate.go | 17 ++-- internal/test/test_database.go | 6 +- internal/todo/repository/inmemory/inmemory.go | 59 ------------ internal/todo/repository/postgres/postgres.go | 31 +++---- .../todo/repository/postgres/postgres_test.go | 1 - internal/todo/repository/sqlite/sqlite.go | 92 ------------------- 7 files changed, 27 insertions(+), 186 deletions(-) delete mode 100644 internal/todo/repository/inmemory/inmemory.go delete mode 100644 internal/todo/repository/sqlite/sqlite.go diff --git a/cmd/main.go b/cmd/main.go index 5541279..ea9288f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,7 +1,7 @@ package main import ( - "database/sql" + "context" "log" "net/http" "os" @@ -12,7 +12,7 @@ import ( todohandler "gitea.michaelthomson.dev/mthomson/habits/internal/todo/handler" todorepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/postgres" todoservice "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" - _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jackc/pgx/v5/pgxpool" ) func main() { @@ -30,7 +30,8 @@ func main() { // create db pool postgresUrl := "postgres://todo:password@localhost:5432/todo" - db, err := sql.Open("pgx", postgresUrl) + // db, err := sql.Open("pgx", postgresUrl) + db, err := pgxpool.New(context.Background(), postgresUrl) if err != nil { logger.Error(err.Error()) os.Exit(1); diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go index a4a4f26..cf8a159 100644 --- a/internal/migrate/migrate.go +++ b/internal/migrate/migrate.go @@ -1,13 +1,14 @@ package migrate import ( - "database/sql" + "context" "embed" "fmt" "log" "log/slog" - _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) //go:embed migrations/*.sql @@ -18,7 +19,7 @@ type Migration struct { Name string } -func Migrate(logger *slog.Logger, db *sql.DB) { +func Migrate(logger *slog.Logger, db *pgxpool.Pool) { logger.Info("Running migrations...") migrationTableSql := ` CREATE TABLE IF NOT EXISTS migrations( @@ -26,7 +27,7 @@ func Migrate(logger *slog.Logger, db *sql.DB) { name VARCHAR(50) );` - _, err := db.Exec(migrationTableSql) + _, err := db.Exec(context.Background(), migrationTableSql) if err != nil { log.Fatal(err) } @@ -38,21 +39,21 @@ func Migrate(logger *slog.Logger, db *sql.DB) { for _, file := range files { 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) - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { logger.Info(fmt.Sprintf("Running migration: %s", file.Name())) migrationSql, err := migrations.ReadFile(fmt.Sprintf("migrations/%s", file.Name())) if err != nil { log.Fatal(err) } - _, err = db.Exec(string(migrationSql)) + _, err = db.Exec(context.Background(), string(migrationSql)) if err != nil { 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 { log.Fatal(err) } diff --git a/internal/test/test_database.go b/internal/test/test_database.go index a6919e0..4496b5e 100644 --- a/internal/test/test_database.go +++ b/internal/test/test_database.go @@ -2,19 +2,19 @@ package test import ( "context" - "database/sql" "log/slog" "testing" "time" "gitea.michaelthomson.dev/mthomson/habits/internal/migrate" + "github.com/jackc/pgx/v5/pgxpool" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" ) type TestDatabase struct { - Db *sql.DB + Db *pgxpool.Pool container testcontainers.Container } @@ -43,7 +43,7 @@ func NewTestDatabase(tb testing.TB) *TestDatabase { } // create db pool - db, err := sql.Open("pgx", connectionString) + db, err := pgxpool.New(context.Background(), connectionString) if err != nil { tb.Fatalf("Failed to open db pool: %v", err) } diff --git a/internal/todo/repository/inmemory/inmemory.go b/internal/todo/repository/inmemory/inmemory.go deleted file mode 100644 index 8d4f109..0000000 --- a/internal/todo/repository/inmemory/inmemory.go +++ /dev/null @@ -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 -} diff --git a/internal/todo/repository/postgres/postgres.go b/internal/todo/repository/postgres/postgres.go index c1c834f..bdf03cb 100644 --- a/internal/todo/repository/postgres/postgres.go +++ b/internal/todo/repository/postgres/postgres.go @@ -2,18 +2,19 @@ package postgres import ( "context" - "database/sql" "log/slog" "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) type PostgresTodoRepository struct { logger *slog.Logger - db *sql.DB + db *pgxpool.Pool } -func NewPostgresTodoRepository(logger *slog.Logger, db *sql.DB) *PostgresTodoRepository { +func NewPostgresTodoRepository(logger *slog.Logger, db *pgxpool.Pool) *PostgresTodoRepository { return &PostgresTodoRepository{ logger: logger, db: db, @@ -23,10 +24,10 @@ func NewPostgresTodoRepository(logger *slog.Logger, db *sql.DB) *PostgresTodoRep 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) + err := r.db.QueryRow(ctx, "SELECT * FROM todo WHERE id = $1;", id).Scan(&todo.Id, &todo.Name, &todo.Done) if err != nil { - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { return todo, repository.ErrNotFound } @@ -38,7 +39,7 @@ func (r *PostgresTodoRepository) GetById(ctx context.Context, id int64) (reposit } 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) + 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 { @@ -50,19 +51,14 @@ func (r *PostgresTodoRepository) Create(ctx context.Context, todo repository.Tod } 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) + 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, err := result.RowsAffected() - - if err != nil { - r.logger.ErrorContext(ctx, err.Error()) - return err - } + rowsAffected := result.RowsAffected() if rowsAffected == 0 { return repository.ErrNotFound @@ -72,19 +68,14 @@ func (r *PostgresTodoRepository) Update(ctx context.Context, todo repository.Tod } func (r *PostgresTodoRepository) Delete(ctx context.Context, id int64) error { - result, err := r.db.Exec("DELETE FROM todo WHERE id = $1;", id) + 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, err := result.RowsAffected() - - if err != nil { - r.logger.ErrorContext(ctx, err.Error()) - return err - } + rowsAffected := result.RowsAffected() if rowsAffected == 0 { return repository.ErrNotFound diff --git a/internal/todo/repository/postgres/postgres_test.go b/internal/todo/repository/postgres/postgres_test.go index a858954..7cb35c2 100644 --- a/internal/todo/repository/postgres/postgres_test.go +++ b/internal/todo/repository/postgres/postgres_test.go @@ -8,7 +8,6 @@ import ( "gitea.michaelthomson.dev/mthomson/habits/internal/test" "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" - _ "github.com/jackc/pgx/v5/stdlib" ) func TestCRUD(t *testing.T) { diff --git a/internal/todo/repository/sqlite/sqlite.go b/internal/todo/repository/sqlite/sqlite.go deleted file mode 100644 index 5988b5c..0000000 --- a/internal/todo/repository/sqlite/sqlite.go +++ /dev/null @@ -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 -} -- 2.47.2 From 651bcce12f688b76c2dbe8bd7fa22f9a414d4779 Mon Sep 17 00:00:00 2001 From: Michael Thomson Date: Thu, 15 May 2025 15:16:55 -0400 Subject: [PATCH 2/9] add users table --- internal/migrate/migrations/1-init_db.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/migrate/migrations/1-init_db.sql b/internal/migrate/migrations/1-init_db.sql index 084a3fa..039ecc3 100644 --- a/internal/migrate/migrations/1-init_db.sql +++ b/internal/migrate/migrations/1-init_db.sql @@ -3,3 +3,9 @@ CREATE TABLE todo( name VARCHAR(50), done BOOLEAN ); + +CREATE TABLE users( + id uuid PRIMARY KEY, + email VARCHAR NOT NULL, + hashed_password VARCHAR NOT NULL +); -- 2.47.2 From 26992f9579e6e03f44c052cc45f127c83ece9381 Mon Sep 17 00:00:00 2001 From: Michael Thomson Date: Thu, 15 May 2025 15:40:57 -0400 Subject: [PATCH 3/9] merged postgres repository with base repository --- cmd/main.go | 4 +- internal/todo/repository/postgres/postgres.go | 85 ------------------- internal/todo/repository/repository.go | 80 +++++++++++++++++ .../postgres_test.go => repository_test.go} | 4 +- internal/todo/service/service_test.go | 10 +-- 5 files changed, 88 insertions(+), 95 deletions(-) delete mode 100644 internal/todo/repository/postgres/postgres.go rename internal/todo/repository/{postgres/postgres_test.go => repository_test.go} (95%) diff --git a/cmd/main.go b/cmd/main.go index ea9288f..7c5971b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -10,7 +10,7 @@ import ( "gitea.michaelthomson.dev/mthomson/habits/internal/middleware" "gitea.michaelthomson.dev/mthomson/habits/internal/migrate" 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" "github.com/jackc/pgx/v5/pgxpool" ) @@ -41,7 +41,7 @@ func main() { migrate.Migrate(logger, db) // create repos - todoRepository := todorepository.NewPostgresTodoRepository(logger, db) + todoRepository := todorepository.NewTodoRepository(logger, db) // create services todoService := todoservice.NewTodoService(logger, todoRepository) diff --git a/internal/todo/repository/postgres/postgres.go b/internal/todo/repository/postgres/postgres.go deleted file mode 100644 index bdf03cb..0000000 --- a/internal/todo/repository/postgres/postgres.go +++ /dev/null @@ -1,85 +0,0 @@ -package postgres - -import ( - "context" - "log/slog" - - "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" -) - -type PostgresTodoRepository struct { - logger *slog.Logger - db *pgxpool.Pool -} - -func NewPostgresTodoRepository(logger *slog.Logger, db *pgxpool.Pool) *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(ctx, "SELECT * FROM todo WHERE id = $1;", id).Scan(&todo.Id, &todo.Name, &todo.Done) - - if err != nil { - if err == pgx.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(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 repository.TodoRow{}, err - } - - return todo, nil -} - -func (r *PostgresTodoRepository) Update(ctx context.Context, todo repository.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 repository.ErrNotFound - } - - return nil -} - -func (r *PostgresTodoRepository) 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 repository.ErrNotFound - } - - return nil -} diff --git a/internal/todo/repository/repository.go b/internal/todo/repository/repository.go index ff46533..39fe063 100644 --- a/internal/todo/repository/repository.go +++ b/internal/todo/repository/repository.go @@ -1,7 +1,12 @@ package repository import ( + "context" "errors" + "log/slog" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) var ( @@ -21,3 +26,78 @@ func NewTodoRow(name string, done bool) TodoRow { func (t TodoRow) Equal(todo TodoRow) bool { 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 +} diff --git a/internal/todo/repository/postgres/postgres_test.go b/internal/todo/repository/repository_test.go similarity index 95% rename from internal/todo/repository/postgres/postgres_test.go rename to internal/todo/repository/repository_test.go index 7cb35c2..ca514a6 100644 --- a/internal/todo/repository/postgres/postgres_test.go +++ b/internal/todo/repository/repository_test.go @@ -1,4 +1,4 @@ -package postgres +package repository_test import ( "context" @@ -15,7 +15,7 @@ func TestCRUD(t *testing.T) { logger := slog.Default() tdb := test.NewTestDatabase(t) defer tdb.TearDown() - r := NewPostgresTodoRepository(logger, tdb.Db) + r := repository.NewTodoRepository(logger, tdb.Db) t.Run("creates new todo", func(t *testing.T) { want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} diff --git a/internal/todo/service/service_test.go b/internal/todo/service/service_test.go index bf1c84c..5a274d2 100644 --- a/internal/todo/service/service_test.go +++ b/internal/todo/service/service_test.go @@ -7,9 +7,7 @@ import ( "gitea.michaelthomson.dev/mthomson/habits/internal/test" "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" - _ "github.com/jackc/pgx/v5/stdlib" ) func TestCreateTodo(t *testing.T) { @@ -18,7 +16,7 @@ func TestCreateTodo(t *testing.T) { logger := slog.Default() tdb := test.NewTestDatabase(t) defer tdb.TearDown() - r := postgres.NewPostgresTodoRepository(logger, tdb.Db) + r := repository.NewTodoRepository(logger, tdb.Db) todoService := service.NewTodoService(logger, r) @@ -37,7 +35,7 @@ func TestGetTodo(t *testing.T) { logger := slog.Default() tdb := test.NewTestDatabase(t) 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} _, err := r.Create(ctx, row) @@ -64,7 +62,7 @@ func TestDeleteTodo(t *testing.T) { logger := slog.Default() tdb := test.NewTestDatabase(t) 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} _, err := r.Create(ctx, row) @@ -91,7 +89,7 @@ func TestUpdateTodo(t *testing.T) { logger := slog.Default() tdb := test.NewTestDatabase(t) 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} _, err := r.Create(ctx, row) -- 2.47.2 From 922ba98f6bc637d0f7eae348a31c7a8596b98f9b Mon Sep 17 00:00:00 2001 From: Michael Thomson Date: Thu, 15 May 2025 15:43:33 -0400 Subject: [PATCH 4/9] update dependencies --- go.mod | 77 ++++++++++++----------- go.sum | 196 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 138 insertions(+), 135 deletions(-) diff --git a/go.mod b/go.mod index 7698d2c..bdc4763 100644 --- a/go.mod +++ b/go.mod @@ -1,65 +1,72 @@ module gitea.michaelthomson.dev/mthomson/habits -go 1.22.9 +go 1.23.0 + +toolchain go1.23.9 require ( - github.com/jackc/pgx/v5 v5.7.2 - github.com/mattn/go-sqlite3 v1.14.24 - github.com/testcontainers/testcontainers-go v0.35.0 - github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.4 + github.com/testcontainers/testcontainers-go v0.37.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 ) require ( - dario.cat/mergo v1.0.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect - github.com/containerd/containerd v1.7.18 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // 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-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.3 // 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-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/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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.17.4 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect + github.com/magiconair/properties v1.8.10 // 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/sys/sequential v0.5.0 // indirect - github.com/moby/sys/user v0.1.0 // indirect - github.com/moby/term v0.5.0 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/sys/sequential v0.6.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/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/pmezard/go-difflib v1.0.0 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/shirou/gopsutil/v3 v3.23.12 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.25.4 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/stretchr/testify v1.9.0 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect - github.com/yusufpapurcu/wmi v1.2.3 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.24.0 // indirect - go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.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 ) diff --git a/go.sum b/go.sum index ed357d5..487f119 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,13 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/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-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 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.2.1/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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -23,27 +21,28 @@ 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/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/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= -github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= +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/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/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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +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/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.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 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/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= @@ -52,118 +51,117 @@ 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 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/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +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/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +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/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 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-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -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/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= 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/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/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= -github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= -github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= -github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +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/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/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= -github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= -github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= -github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= -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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw= +github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 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.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/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= -github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= -github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 h1:eEGx9kYzZb2cNhRbBrNOCL/YPOM7+RMJiy3bB+ie0/I= -github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0/go.mod h1:hfH71Mia/WWLBgMD2YctYcMlfsbnT0hflweL1dy8Q4s= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= +github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/yuin/goldmark v1.1.27/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.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= -go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +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/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/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= -go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= -go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= -go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= -go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= -go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +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/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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +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.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-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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +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-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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -171,17 +169,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-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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +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/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -192,20 +188,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-20191204190536-9bdfabe68543/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/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-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= +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/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -- 2.47.2 From af4b0190d0730d9f29e959e8609341bf97f389cf Mon Sep 17 00:00:00 2001 From: Michael Thomson Date: Thu, 15 May 2025 16:01:07 -0400 Subject: [PATCH 5/9] add uuid support --- cmd/main.go | 18 +++++++++++++++--- go.mod | 2 ++ go.sum | 6 ++++++ internal/test/test_database.go | 14 +++++++++++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 7c5971b..4875051 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -12,7 +12,9 @@ import ( todohandler "gitea.michaelthomson.dev/mthomson/habits/internal/todo/handler" todorepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" todoservice "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + pgxuuid "github.com/jackc/pgx-gofrs-uuid" ) func main() { @@ -30,11 +32,21 @@ func main() { // create db pool postgresUrl := "postgres://todo:password@localhost:5432/todo" - // db, err := sql.Open("pgx", postgresUrl) - db, err := pgxpool.New(context.Background(), postgresUrl) + dbconfig, err := pgxpool.ParseConfig(postgresUrl) if err != nil { 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 diff --git a/go.mod b/go.mod index bdc4763..55b71aa 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.23.9 require ( github.com/google/uuid v1.6.0 + github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 github.com/jackc/pgx/v5 v5.7.4 github.com/testcontainers/testcontainers-go v0.37.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 @@ -29,6 +30,7 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gofrs/uuid/v5 v5.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index 487f119..0d6908a 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,10 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre 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.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M= +github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +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/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -51,6 +55,8 @@ 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 h1:QWdhlQz98hUe1xmjADOl2mr8ERLrOqj0KWLdkrnNsRQ= +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= diff --git a/internal/test/test_database.go b/internal/test/test_database.go index 4496b5e..126e9d2 100644 --- a/internal/test/test_database.go +++ b/internal/test/test_database.go @@ -7,10 +7,12 @@ import ( "time" "gitea.michaelthomson.dev/mthomson/habits/internal/migrate" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" + pgxuuid "github.com/jackc/pgx-gofrs-uuid" ) type TestDatabase struct { @@ -42,8 +44,18 @@ func NewTestDatabase(tb testing.TB) *TestDatabase { 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 - db, err := pgxpool.New(context.Background(), connectionString) + db, err := pgxpool.NewWithConfig(context.Background(), dbconfig) if err != nil { tb.Fatalf("Failed to open db pool: %v", err) } -- 2.47.2 From b4b634fd3f27f92d5476f9f8ed06517f8002fa15 Mon Sep 17 00:00:00 2001 From: Michael Thomson Date: Fri, 16 May 2025 10:40:20 -0400 Subject: [PATCH 6/9] use gofrs uuid --- go.mod | 4 ++-- go.sum | 2 -- internal/middleware/context.go | 7 +++++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 55b71aa..016e2e7 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.23.0 toolchain go1.23.9 require ( - github.com/google/uuid v1.6.0 + github.com/gofrs/uuid/v5 v5.3.2 github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 github.com/jackc/pgx/v5 v5.7.4 github.com/testcontainers/testcontainers-go v0.37.0 @@ -30,8 +30,8 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/gofrs/uuid/v5 v5.3.2 // indirect github.com/gogo/protobuf v1.3.2 // 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index 0d6908a..4d00626 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,6 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre 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.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M= -github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= 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= diff --git a/internal/middleware/context.go b/internal/middleware/context.go index 35211b3..89f4b6e 100644 --- a/internal/middleware/context.go +++ b/internal/middleware/context.go @@ -5,7 +5,7 @@ import ( "log/slog" "net/http" - "github.com/google/uuid" + "github.com/gofrs/uuid/v5" ) type contextKey string @@ -14,7 +14,10 @@ const TraceIdKey contextKey = "trace_id" func ContextMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - traceid := uuid.NewString() + traceid, err := uuid.NewV4() + if err != nil { + logger.ErrorContext(r.Context(), err.Error()) + } ctx := context.WithValue(r.Context(), TraceIdKey, traceid) newReq := r.WithContext(ctx) -- 2.47.2 From 1ffbbcec15f85ac3628f15a6f13c297cf2469f56 Mon Sep 17 00:00:00 2001 From: Michael Thomson Date: Fri, 16 May 2025 11:21:48 -0400 Subject: [PATCH 7/9] user repository --- internal/migrate/migrations/1-init_db.sql | 2 +- internal/user/repository/repository.go | 115 ++++++++++++++++++++ internal/user/repository/repository_test.go | 84 ++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 internal/user/repository/repository.go create mode 100644 internal/user/repository/repository_test.go diff --git a/internal/migrate/migrations/1-init_db.sql b/internal/migrate/migrations/1-init_db.sql index 039ecc3..bc2f837 100644 --- a/internal/migrate/migrations/1-init_db.sql +++ b/internal/migrate/migrations/1-init_db.sql @@ -5,7 +5,7 @@ CREATE TABLE todo( ); CREATE TABLE users( - id uuid PRIMARY KEY, + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR NOT NULL, hashed_password VARCHAR NOT NULL ); diff --git a/internal/user/repository/repository.go b/internal/user/repository/repository.go new file mode 100644 index 0000000..0dc657d --- /dev/null +++ b/internal/user/repository/repository.go @@ -0,0 +1,115 @@ +package repository + +import ( + "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 string +} + +func NewUserRow(id uuid.UUID, email string, hashedPassword string) UserRow { + return UserRow{Id: id, Email: email, HashedPassword: hashedPassword} +} + +func (u UserRow) Equal(user UserRow) bool { + return u.Id == user.Id && u.Email == user.Email && u.HashedPassword == user.HashedPassword +} + +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) + + 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) VALUES ($1, $2) RETURNING id;", user.Email, user.HashedPassword) + + 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) VALUES ($1, $2, $3);", user.Id, user.Email, user.HashedPassword) + + 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 WHERE id = $3;", user.Email, user.HashedPassword, 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 +} diff --git a/internal/user/repository/repository_test.go b/internal/user/repository/repository_test.go new file mode 100644 index 0000000..0e8ccdc --- /dev/null +++ b/internal/user/repository/repository_test.go @@ -0,0 +1,84 @@ +package repository_test + +import ( + "context" + "errors" + "log/slog" + "testing" + + "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) + + t.Run("creates new user", func(t *testing.T) { + newUser := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} + _, err := r.Create(ctx, newUser) + AssertNoError(t, err) + }) + + t.Run("gets user", func(t *testing.T) { + want := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} + got, err := r.GetById(ctx, uuid) + 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: "supersecurehash"} + 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 +} -- 2.47.2 From 70bb4e66b49422ec20f84a7b0ac548ea88dd8bc6 Mon Sep 17 00:00:00 2001 From: Michael Thomson Date: Fri, 16 May 2025 12:33:40 -0400 Subject: [PATCH 8/9] user service --- internal/user/service/service.go | 113 +++++++++++++++++++ internal/user/service/service_test.go | 155 ++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 internal/user/service/service.go create mode 100644 internal/user/service/service_test.go diff --git a/internal/user/service/service.go b/internal/user/service/service.go new file mode 100644 index 0000000..22bed69 --- /dev/null +++ b/internal/user/service/service.go @@ -0,0 +1,113 @@ +package service + +import ( + "context" + "errors" + "log/slog" + + "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository" + "github.com/gofrs/uuid/v5" +) + +var ( + ErrNotFound error = errors.New("user cannot be found") +) + +type User struct { + Id uuid.UUID + Email string + HashedPassword string +} + +func NewUser(id uuid.UUID, email string, hashedPassword string) User { + return User{Id: id, Email: email, HashedPassword: hashedPassword} +} + +func UserFromUserRow(userRow repository.UserRow) User { + return User{Id: userRow.Id, Email: userRow.Email, HashedPassword: userRow.HashedPassword} +} + +func UserRowFromUser(user User) repository.UserRow { + return repository.UserRow{Id: user.Id, Email: user.Email, HashedPassword: user.HashedPassword} +} + +func (t User) Equal(user User) bool { + return t.Id == user.Id && t.Email == user.Email && t.HashedPassword == user.HashedPassword +} + +type UserRepository interface { + Create(ctx context.Context, user repository.UserRow) (repository.UserRow, error) + GetById(ctx context.Context, id uuid.UUID) (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 +} + +func NewUserService(logger *slog.Logger, userRepo UserRepository) *UserService { + return &UserService{ + logger: logger, + repo: userRepo, + } +} + +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) CreateUser(ctx context.Context, user User) (User, error) { + userRow := UserRowFromUser(user) + + newUserRow, err := s.repo.Create(ctx, userRow) + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + return User{}, err + } + + return UserFromUserRow(newUserRow), 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) UpdateUser(ctx context.Context, user User) error { + userRow := UserRowFromUser(user) + + err := s.repo.Update(ctx, userRow) + + if err == repository.ErrNotFound { + return ErrNotFound + } + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + } + + return err +} diff --git a/internal/user/service/service_test.go b/internal/user/service/service_test.go new file mode 100644 index 0000000..4e5ccbc --- /dev/null +++ b/internal/user/service/service_test.go @@ -0,0 +1,155 @@ +package service_test + +import ( + "context" + "log/slog" + "testing" + + "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 TestCreateUser(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slog.Default() + tdb := test.NewTestDatabase(t) + defer tdb.TearDown() + r := repository.NewUserRepository(logger, tdb.Db) + + userService := service.NewUserService(logger, r) + + t.Run("Create user", func(t *testing.T) { + uuid := NewUUID(t) + user := service.NewUser(uuid, "test@test.com", "supersecurehash") + + _, err := userService.CreateUser(ctx, user) + + 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) + uuid := NewUUID(t) + + row := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} + _, err := r.Create(ctx, row) + AssertNoError(t, err); + + userService := service.NewUserService(logger, r) + + 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) + uuid := NewUUID(t) + + row := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} + _, err := r.Create(ctx, row) + AssertNoError(t, err); + + userService := service.NewUserService(logger, r) + + 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 TestUpdateUser(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slog.Default() + tdb := test.NewTestDatabase(t) + defer tdb.TearDown() + r := repository.NewUserRepository(logger, tdb.Db) + uuid := NewUUID(t) + + row := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} + _, err := r.Create(ctx, row) + AssertNoError(t, err); + + userService := service.NewUserService(logger, r) + + t.Run("Update exisiting user", func(t *testing.T) { + user := service.User{uuid, "new@email.com", "supersecurehash"} + + err := userService.UpdateUser(ctx, user) + + AssertNoError(t, err) + + newUser, err := userService.GetUser(ctx, uuid) + + AssertNoError(t, err) + + AssertUsers(t, newUser, user) + }) + + t.Run("Update non-existant user", func(t *testing.T) { + user := service.User{NewUUID(t), "new@email.com", "supersecurehash"} + + err := userService.UpdateUser(ctx, user) + + 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 +} -- 2.47.2 From e55d419d44f6d9a0fc70c902039bc1485becd2b0 Mon Sep 17 00:00:00 2001 From: Michael Thomson Date: Thu, 22 May 2025 13:55:43 -0400 Subject: [PATCH 9/9] auth services, middleware, and other stuff --- .env.example | 2 + cmd/main.go | 58 +++++-- go.mod | 4 +- go.sum | 4 + internal/auth/handler/login.go | 66 ++++++++ internal/auth/handler/login_test.go | 168 +++++++++++++++++++ internal/auth/hashing.go | 70 ++++++++ internal/auth/service/service.go | 90 ++++++++++ internal/auth/service/service_test.go | 81 +++++++++ internal/logging/logging.go | 2 +- internal/middleware/auth.go | 60 +++++++ internal/middleware/{context.go => trace.go} | 5 +- internal/middleware/util.go | 2 + internal/migrate/migrations/1-init_db.sql | 5 +- internal/test/test_database.go | 2 +- internal/todo/service/service_test.go | 6 +- internal/user/handler/register.go | 67 ++++++++ internal/user/handler/register_test.go | 148 ++++++++++++++++ internal/user/repository/repository.go | 35 ++-- internal/user/repository/repository_test.go | 23 ++- internal/user/service/service.go | 128 ++++++++++---- internal/user/service/service_test.go | 54 +++--- 22 files changed, 985 insertions(+), 95 deletions(-) create mode 100644 .env.example create mode 100644 internal/auth/handler/login.go create mode 100644 internal/auth/handler/login_test.go create mode 100644 internal/auth/hashing.go create mode 100644 internal/auth/service/service.go create mode 100644 internal/auth/service/service_test.go create mode 100644 internal/middleware/auth.go rename internal/middleware/{context.go => trace.go} (72%) create mode 100644 internal/user/handler/register.go create mode 100644 internal/user/handler/register_test.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b4044f1 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +POSTGRESQL_CONNECTION_STRING=postgres://todo:password@localhost:5432/todo +JWT_SECRET_KEY="supersecretjwtkey" diff --git a/cmd/main.go b/cmd/main.go index 4875051..97d9da7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,33 +6,59 @@ import ( "net/http" "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/middleware" "gitea.michaelthomson.dev/mthomson/habits/internal/migrate" todohandler "gitea.michaelthomson.dev/mthomson/habits/internal/todo/handler" todorepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" todoservice "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" + 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" - pgxuuid "github.com/jackc/pgx-gofrs-uuid" + "github.com/joho/godotenv" ) func main() { // create logger logger := logging.NewLogger() - // create middlewares - contextMiddleware := middleware.ContextMiddleware(logger) - loggingMiddleware := middleware.LoggingMiddleware(logger) + // load env + err := godotenv.Load() + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } - stack := []middleware.Middleware{ - contextMiddleware, + jwtSecretKey := os.Getenv("JWT_SECRET_KEY") + 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, } + authenticatedStack := []middleware.Middleware{ + traceMiddleware, + loggingMiddleware, + authMiddleware, + } + // create db pool - postgresUrl := "postgres://todo:password@localhost:5432/todo" - dbconfig, err := pgxpool.ParseConfig(postgresUrl) + dbconfig, err := pgxpool.ParseConfig(postgresqlConnectionString) if err != nil { logger.Error(err.Error()) os.Exit(1) @@ -54,18 +80,26 @@ func main() { // create repos todoRepository := todorepository.NewTodoRepository(logger, db) + userRepository := userrepository.NewUserRepository(logger, db) // create services todoService := todoservice.NewTodoService(logger, todoRepository) + userService := userservice.NewUserService(logger, userRepository, argon2IdHash) + authService := authservice.NewAuthService(logger, []byte(jwtSecretKey), userRepository, argon2IdHash) // create mux mux := http.NewServeMux() // register handlers - mux.Handle("GET /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoGet(logger, todoService), stack)) - mux.Handle("POST /todo", middleware.CompileMiddleware(todohandler.HandleTodoCreate(logger, todoService), stack)) - mux.Handle("DELETE /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoDelete(logger, todoService), stack)) - mux.Handle("PUT /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoUpdate(logger, todoService), stack)) + // auth + mux.Handle("POST /login", middleware.CompileMiddleware(authhandler.HandleLogin(logger, authService), unauthenticatedStack)) + // users + 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 server := &http.Server{ diff --git a/go.mod b/go.mod index 016e2e7..3375f33 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,13 @@ toolchain go1.23.9 require ( github.com/gofrs/uuid/v5 v5.3.2 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 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 ( @@ -64,7 +67,6 @@ require ( go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect - golang.org/x/crypto v0.38.0 // indirect golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect diff --git a/go.sum b/go.sum index 4d00626..2dcb4f7 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ 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/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -59,6 +61,8 @@ 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/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/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= diff --git a/internal/auth/handler/login.go b/internal/auth/handler/login.go new file mode 100644 index 0000000..03fac61 --- /dev/null +++ b/internal/auth/handler/login.go @@ -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) + } +} diff --git a/internal/auth/handler/login_test.go b/internal/auth/handler/login_test.go new file mode 100644 index 0000000..0d3c389 --- /dev/null +++ b/internal/auth/handler/login_test.go @@ -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) + } +} diff --git a/internal/auth/hashing.go b/internal/auth/hashing.go new file mode 100644 index 0000000..9610c9b --- /dev/null +++ b/internal/auth/hashing.go @@ -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 +} diff --git a/internal/auth/service/service.go b/internal/auth/service/service.go new file mode 100644 index 0000000..1dabbae --- /dev/null +++ b/internal/auth/service/service.go @@ -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 +} diff --git a/internal/auth/service/service_test.go b/internal/auth/service/service_test.go new file mode 100644 index 0000000..cffa456 --- /dev/null +++ b/internal/auth/service/service_test.go @@ -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 +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index df6f811..d967cc6 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -20,7 +20,7 @@ func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error { } 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} logger := slog.New(customHandler) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..3728a95 --- /dev/null +++ b/internal/middleware/auth.go @@ -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) + }) + } +} diff --git a/internal/middleware/context.go b/internal/middleware/trace.go similarity index 72% rename from internal/middleware/context.go rename to internal/middleware/trace.go index 89f4b6e..3029add 100644 --- a/internal/middleware/context.go +++ b/internal/middleware/trace.go @@ -8,17 +8,16 @@ import ( "github.com/gofrs/uuid/v5" ) -type contextKey string 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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { traceid, err := uuid.NewV4() if err != nil { logger.ErrorContext(r.Context(), err.Error()) } - ctx := context.WithValue(r.Context(), TraceIdKey, traceid) + ctx := context.WithValue(r.Context(), TraceIdKey, traceid.String()) newReq := r.WithContext(ctx) next.ServeHTTP(w, newReq) diff --git a/internal/middleware/util.go b/internal/middleware/util.go index 50ed4fb..cf06793 100644 --- a/internal/middleware/util.go +++ b/internal/middleware/util.go @@ -4,6 +4,8 @@ import "net/http" type Middleware func(http.Handler) http.Handler +type contextKey string + func CompileMiddleware(h http.Handler, m []Middleware) http.Handler { if len(m) < 1 { return h diff --git a/internal/migrate/migrations/1-init_db.sql b/internal/migrate/migrations/1-init_db.sql index bc2f837..79d92cc 100644 --- a/internal/migrate/migrations/1-init_db.sql +++ b/internal/migrate/migrations/1-init_db.sql @@ -6,6 +6,7 @@ CREATE TABLE todo( CREATE TABLE users( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - email VARCHAR NOT NULL, - hashed_password VARCHAR NOT NULL + email VARCHAR NOT NULL UNIQUE, + hashed_password bytea NOT NULL, + salt bytea NOT NULL ); diff --git a/internal/test/test_database.go b/internal/test/test_database.go index 126e9d2..1ca7562 100644 --- a/internal/test/test_database.go +++ b/internal/test/test_database.go @@ -7,12 +7,12 @@ import ( "time" "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/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" - pgxuuid "github.com/jackc/pgx-gofrs-uuid" ) type TestDatabase struct { diff --git a/internal/todo/service/service_test.go b/internal/todo/service/service_test.go index 5a274d2..4f9ea5a 100644 --- a/internal/todo/service/service_test.go +++ b/internal/todo/service/service_test.go @@ -39,7 +39,7 @@ func TestGetTodo(t *testing.T) { row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} _, err := r.Create(ctx, row) - AssertNoError(t, err); + AssertNoError(t, err) todoService := service.NewTodoService(logger, r) @@ -66,7 +66,7 @@ func TestDeleteTodo(t *testing.T) { row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} _, err := r.Create(ctx, row) - AssertNoError(t, err); + AssertNoError(t, err) todoService := service.NewTodoService(logger, r) @@ -93,7 +93,7 @@ func TestUpdateTodo(t *testing.T) { row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} _, err := r.Create(ctx, row) - AssertNoError(t, err); + AssertNoError(t, err) todoService := service.NewTodoService(logger, r) diff --git a/internal/user/handler/register.go b/internal/user/handler/register.go new file mode 100644 index 0000000..98d9f1b --- /dev/null +++ b/internal/user/handler/register.go @@ -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 + } + + } +} diff --git a/internal/user/handler/register_test.go b/internal/user/handler/register_test.go new file mode 100644 index 0000000..4cd1c43 --- /dev/null +++ b/internal/user/handler/register_test.go @@ -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 +} diff --git a/internal/user/repository/repository.go b/internal/user/repository/repository.go index 0dc657d..72dc086 100644 --- a/internal/user/repository/repository.go +++ b/internal/user/repository/repository.go @@ -1,6 +1,7 @@ package repository import ( + "bytes" "context" "errors" "log/slog" @@ -17,15 +18,12 @@ var ( type UserRow struct { Id uuid.UUID Email string - HashedPassword string -} - -func NewUserRow(id uuid.UUID, email string, hashedPassword string) UserRow { - return UserRow{Id: id, Email: email, HashedPassword: hashedPassword} + HashedPassword []byte + Salt []byte } func (u UserRow) Equal(user UserRow) bool { - return u.Id == user.Id && u.Email == user.Email && u.HashedPassword == user.HashedPassword + return u.Id == user.Id && u.Email == user.Email && bytes.Equal(u.HashedPassword, user.HashedPassword) && bytes.Equal(u.Salt, user.Salt) } type UserRepository struct { @@ -43,7 +41,24 @@ func NewUserRepository(logger *slog.Logger, db *pgxpool.Pool) *UserRepository { 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) + 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 { @@ -60,7 +75,7 @@ func (r *UserRepository) GetById(ctx context.Context, id uuid.UUID) (UserRow, er 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) VALUES ($1, $2) RETURNING id;", user.Email, user.HashedPassword) + 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) @@ -69,7 +84,7 @@ func (r *UserRepository) Create(ctx context.Context, user UserRow) (UserRow, err return UserRow{}, err } } else { - _, err := r.db.Exec(ctx, "INSERT INTO users (id, email, hashed_password) VALUES ($1, $2, $3);", user.Id, user.Email, user.HashedPassword) + _, 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()) @@ -81,7 +96,7 @@ func (r *UserRepository) Create(ctx context.Context, user UserRow) (UserRow, err } func (r *UserRepository) Update(ctx context.Context, user UserRow) error { - result, err := r.db.Exec(ctx, "UPDATE users SET email = $1, hashed_password = $2 WHERE id = $3;", user.Email, user.HashedPassword, user.Id) + 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()) diff --git a/internal/user/repository/repository_test.go b/internal/user/repository/repository_test.go index 0e8ccdc..79d3933 100644 --- a/internal/user/repository/repository_test.go +++ b/internal/user/repository/repository_test.go @@ -6,6 +6,7 @@ import ( "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" @@ -18,22 +19,36 @@ func TestCRUD(t *testing.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: "supersecurehash"} + 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", func(t *testing.T) { - want := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} + 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: "supersecurehash"} + want := repository.UserRow{Id: uuid, Email: "new@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt} err := r.Update(ctx, want) AssertNoError(t, err) diff --git a/internal/user/service/service.go b/internal/user/service/service.go index 22bed69..fac8c2e 100644 --- a/internal/user/service/service.go +++ b/internal/user/service/service.go @@ -5,52 +5,48 @@ import ( "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") + ErrNotFound error = errors.New("user cannot be found") + ErrUserExists error = errors.New("user already exists") ) type User struct { - Id uuid.UUID - Email string - HashedPassword string -} - -func NewUser(id uuid.UUID, email string, hashedPassword string) User { - return User{Id: id, Email: email, HashedPassword: hashedPassword} + Id uuid.UUID + Email string } func UserFromUserRow(userRow repository.UserRow) User { - return User{Id: userRow.Id, Email: userRow.Email, HashedPassword: userRow.HashedPassword} -} - -func UserRowFromUser(user User) repository.UserRow { - return repository.UserRow{Id: user.Id, Email: user.Email, HashedPassword: user.HashedPassword} + return User{Id: userRow.Id, Email: userRow.Email} } func (t User) Equal(user User) bool { - return t.Id == user.Id && t.Email == user.Email && t.HashedPassword == user.HashedPassword + 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 + logger *slog.Logger + repo UserRepository + argon2IdHash *auth.Argon2IdHash } -func NewUserService(logger *slog.Logger, userRepo UserRepository) *UserService { +func NewUserService(logger *slog.Logger, userRepo UserRepository, argon2IdHash *auth.Argon2IdHash) *UserService { return &UserService{ - logger: logger, - repo: userRepo, + logger: logger, + repo: userRepo, + argon2IdHash: argon2IdHash, } } @@ -69,17 +65,37 @@ func (s *UserService) GetUser(ctx context.Context, id uuid.UUID) (User, error) { return UserFromUserRow(user), err } -func (s *UserService) CreateUser(ctx context.Context, user User) (User, error) { - userRow := UserRowFromUser(user) - - newUserRow, err := s.repo.Create(ctx, userRow) +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 User{}, err + return uuid, err } - return UserFromUserRow(newUserRow), 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 { @@ -96,10 +112,66 @@ func (s *UserService) DeleteUser(ctx context.Context, id uuid.UUID) error { return err } -func (s *UserService) UpdateUser(ctx context.Context, user User) error { - userRow := UserRowFromUser(user) +func (s *UserService) UpdateUserEmail(ctx context.Context, id uuid.UUID, email string) error { + user, err := s.repo.GetById(ctx, id) - err := s.repo.Update(ctx, userRow) + 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 diff --git a/internal/user/service/service_test.go b/internal/user/service/service_test.go index 4e5ccbc..d63f67e 100644 --- a/internal/user/service/service_test.go +++ b/internal/user/service/service_test.go @@ -5,27 +5,26 @@ import ( "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 TestCreateUser(t *testing.T) { +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) + userService := service.NewUserService(logger, r, argon2IdHash) t.Run("Create user", func(t *testing.T) { - uuid := NewUUID(t) - user := service.NewUser(uuid, "test@test.com", "supersecurehash") - - _, err := userService.CreateUser(ctx, user) + _, err := userService.Register(ctx, "test@test.com", "supersecurepassword") AssertNoError(t, err) }) @@ -38,13 +37,12 @@ func TestGetUser(t *testing.T) { 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) - row := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} - _, err := r.Create(ctx, row) - AssertNoError(t, err); + userService := service.NewUserService(logger, r, argon2IdHash) - userService := service.NewUserService(logger, r) + 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) @@ -66,13 +64,12 @@ func TestDeleteUser(t *testing.T) { 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) - row := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} - _, err := r.Create(ctx, row) - AssertNoError(t, err); + userService := service.NewUserService(logger, r, argon2IdHash) - userService := service.NewUserService(logger, r) + 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) @@ -87,25 +84,22 @@ func TestDeleteUser(t *testing.T) { }) } -func TestUpdateUser(t *testing.T) { +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) - uuid := NewUUID(t) + argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256) - row := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: "supersecurehash"} - _, err := r.Create(ctx, row) - AssertNoError(t, err); + userService := service.NewUserService(logger, r, argon2IdHash) - userService := service.NewUserService(logger, r) + uuid, err := userService.Register(ctx, "test@test.com", "supersecurepassword") + AssertNoError(t, err) - t.Run("Update exisiting user", func(t *testing.T) { - user := service.User{uuid, "new@email.com", "supersecurehash"} - - err := userService.UpdateUser(ctx, user) + t.Run("Update existing user email", func(t *testing.T) { + err := userService.UpdateUserEmail(ctx, uuid, "newemail@test.com") AssertNoError(t, err) @@ -113,13 +107,13 @@ func TestUpdateUser(t *testing.T) { AssertNoError(t, err) - AssertUsers(t, newUser, user) + 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) { - user := service.User{NewUUID(t), "new@email.com", "supersecurehash"} - - err := userService.UpdateUser(ctx, user) + err := userService.UpdateUserEmail(ctx, NewUUID(t), "newemail@test.com") AssertErrors(t, err, service.ErrNotFound) }) -- 2.47.2