From 34079c3cc70552657cd0e6097ca784647d3c3310 Mon Sep 17 00:00:00 2001 From: Fellipe Leonardo <112525075+neoRandom@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:26:48 -0300 Subject: [PATCH 1/8] chore: add custom material icon settings --- .gitignore | 3 +-- .vscode/settings.json | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 764148b..d8d0279 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,4 @@ go.work.sum .env # Editor/IDE -.idea/ -.vscode/ \ No newline at end of file +.idea/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ea709f6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "material-icon-theme.folders.associations": { + "internal": "src", + "port": "interface", + "usecase": "controller", + "queries": "scripts", + "driven": "connection", + "driver": "trigger" + } +} From bbfc9d8ad0b1f3f53b1a400576e39f104ce69a45 Mon Sep 17 00:00:00 2001 From: Fellipe Leonardo <112525075+neoRandom@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:30:57 -0300 Subject: [PATCH 2/8] refactor: improve packages naming and path - internal/constant/ -> internal/infrastructure/constant/ - internal/core/interface/{driver/driven}/ -> internal/core/port/ - internal/infrastructure/driver/{cli.go/cli_test.go} ->internal/infrastructure/driver/cliadapter/ --- cmd/manager/main.go | 6 +-- internal/application/usecase/game_commands.go | 8 ++-- .../application/usecase/player_management.go | 6 +-- .../core/{interface/driven => port}/driven.go | 2 +- .../core/{interface/driver => port}/driver.go | 2 +- internal/core/service/authentication_test.go | 45 ++++++++++--------- internal/core/service/shared_data.go | 2 +- .../{ => infrastructure}/constant/constant.go | 0 .../driver/{ => cliadapter}/cli.go | 16 +++---- .../driver/{ => cliadapter}/cli_test.go | 24 +++++----- 10 files changed, 56 insertions(+), 55 deletions(-) rename internal/core/{interface/driven => port}/driven.go (83%) rename internal/core/{interface/driver => port}/driver.go (99%) rename internal/{ => infrastructure}/constant/constant.go (100%) rename internal/infrastructure/driver/{ => cliadapter}/cli.go (97%) rename internal/infrastructure/driver/{ => cliadapter}/cli_test.go (91%) diff --git a/cmd/manager/main.go b/cmd/manager/main.go index e1fbc86..3588845 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -12,7 +12,7 @@ import ( "keep-it-up/internal/application/usecase" "keep-it-up/internal/infrastructure/database" "keep-it-up/internal/infrastructure/driven" - clidriver "keep-it-up/internal/infrastructure/driver" + "keep-it-up/internal/infrastructure/driver/cliadapter" "keep-it-up/internal/infrastructure/util" _ "modernc.org/sqlite" @@ -53,7 +53,7 @@ func run() int { // --- Application side: use cases implementing the driver ports ---- auth := usecase.NewAuthentication(q) - deps := clidriver.Deps{ + deps := cliadapter.Deps{ Games: usecase.NewGameManagement(q), Access: usecase.NewAccessManagement(q), Players: usecase.NewPlayerManagement(q, auth), @@ -65,7 +65,7 @@ func run() int { } // --- Driver side: CLI adapter -------------------------------------- - cli := clidriver.New(deps) + cli := cliadapter.New(deps) if err := cli.Run(ctx, os.Args[1:]); err != nil { fmt.Println(err) diff --git a/internal/application/usecase/game_commands.go b/internal/application/usecase/game_commands.go index f2ea392..2f7e6e2 100644 --- a/internal/application/usecase/game_commands.go +++ b/internal/application/usecase/game_commands.go @@ -5,17 +5,17 @@ import ( "database/sql" "errors" "fmt" - "keep-it-up/internal/constant" - "keep-it-up/internal/core/interface/driven" + "keep-it-up/internal/core/port" + "keep-it-up/internal/infrastructure/constant" "keep-it-up/internal/infrastructure/database" ) type GameCommands struct { q *database.Queries - tp driven.TimeProvider + tp port.TimeProvider } -func NewGameCommands(q *database.Queries, tp driven.TimeProvider) *GameCommands { +func NewGameCommands(q *database.Queries, tp port.TimeProvider) *GameCommands { return &GameCommands{ q: q, tp: tp, } diff --git a/internal/application/usecase/player_management.go b/internal/application/usecase/player_management.go index 52720e9..ef96853 100644 --- a/internal/application/usecase/player_management.go +++ b/internal/application/usecase/player_management.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "keep-it-up/internal/core/interface/driver" + "keep-it-up/internal/core/port" "keep-it-up/internal/core/service" "keep-it-up/internal/infrastructure/database" "keep-it-up/internal/infrastructure/util" @@ -13,10 +13,10 @@ import ( type PlayerManagement struct { q *database.Queries - auth driver.Authentication + auth port.Authentication } -func NewPlayerManagement(q *database.Queries, auth driver.Authentication) *PlayerManagement { +func NewPlayerManagement(q *database.Queries, auth port.Authentication) *PlayerManagement { if auth == nil { return nil } diff --git a/internal/core/interface/driven/driven.go b/internal/core/port/driven.go similarity index 83% rename from internal/core/interface/driven/driven.go rename to internal/core/port/driven.go index 926d13f..83a07fd 100644 --- a/internal/core/interface/driven/driven.go +++ b/internal/core/port/driven.go @@ -1,4 +1,4 @@ -package driven +package port import "time" diff --git a/internal/core/interface/driver/driver.go b/internal/core/port/driver.go similarity index 99% rename from internal/core/interface/driver/driver.go rename to internal/core/port/driver.go index d6ef3e3..6115cd4 100644 --- a/internal/core/interface/driver/driver.go +++ b/internal/core/port/driver.go @@ -1,4 +1,4 @@ -package driver +package port import ( "context" diff --git a/internal/core/service/authentication_test.go b/internal/core/service/authentication_test.go index 84b8390..8e304ea 100644 --- a/internal/core/service/authentication_test.go +++ b/internal/core/service/authentication_test.go @@ -1,6 +1,7 @@ -package service +package service_test import ( + "keep-it-up/internal/core/service" "testing" "golang.org/x/crypto/bcrypt" @@ -8,8 +9,8 @@ import ( func TestAuthentication_IsPasswordValid(t *testing.T) { for _, password := range []string{"secret123", "abc123"} { - if err := IsPasswordValid(password); err != nil { - t.Fatalf("IsPasswordValid() rejected a valid password %q: %v", password, err) + if err := service.IsPasswordValid(password); err != nil { + t.Fatalf("service.IsPasswordValid() rejected a valid password %q: %v", password, err) } } @@ -18,33 +19,33 @@ func TestAuthentication_IsPasswordValid(t *testing.T) { "alice", " alice ", " secret123 ", "secret123 ", " secret123", "\tsecret 123\n", } { - if err := IsPasswordValid(password); err == nil { - t.Fatalf("IsPasswordValid() accepted an invalid password %q", password) + if err := service.IsPasswordValid(password); err == nil { + t.Fatalf("service.IsPasswordValid() accepted an invalid password %q", password) } } } func TestAuthentication_IsPasswordValidBoundary(t *testing.T) { - if err := IsPasswordValid("abc123"); err != nil { - t.Fatalf("IsPasswordValid() rejected valid 6-character password: %v", err) + if err := service.IsPasswordValid("abc123"); err != nil { + t.Fatalf("service.IsPasswordValid() rejected valid 6-character password: %v", err) } // Test one below boundary: 5 characters (should fail) - if err := IsPasswordValid("abc12"); err == nil { - t.Fatal("IsPasswordValid() accepted 5-character password (below minimum)") + if err := service.IsPasswordValid("abc12"); err == nil { + t.Fatal("service.IsPasswordValid() accepted 5-character password (below minimum)") } // Test with whitespace at boundary - if err := IsPasswordValid(" abc123"); err == nil { - t.Fatalf("IsPasswordValid() accepted invalid 6-char password with leading space: %v", err) + if err := service.IsPasswordValid(" abc123"); err == nil { + t.Fatalf("service.IsPasswordValid() accepted invalid 6-char password with leading space: %v", err) } - if err := IsPasswordValid("abc123 "); err == nil { - t.Fatalf("IsPasswordValid() accepted invalid 6-char password with trailing space: %v", err) + if err := service.IsPasswordValid("abc123 "); err == nil { + t.Fatalf("service.IsPasswordValid() accepted invalid 6-char password with trailing space: %v", err) } } func TestAuthentication_GeneratePasswordHash(t *testing.T) { - hash, err := GeneratePasswordHash("secret123") + hash, err := service.GeneratePasswordHash("secret123") if err != nil { t.Fatalf("GeneratePasswordHash() returned error: %v", err) } @@ -61,7 +62,7 @@ func TestAuthentication_GeneratePasswordHash(t *testing.T) { } func TestAuthentication_GeneratePasswordHashRejectsShortPassword(t *testing.T) { - if _, err := GeneratePasswordHash("short"); err == nil { + if _, err := service.GeneratePasswordHash("short"); err == nil { t.Fatal("GeneratePasswordHash() accepted a password shorter than 6 characters") } } @@ -81,12 +82,12 @@ func TestAuthentication_IsPasswordValidBoundaryPrecision(t *testing.T) { } for _, tc := range testCases { - err := IsPasswordValid(tc.password) + err := service.IsPasswordValid(tc.password) if tc.shouldValidate && err != nil { - t.Fatalf("IsPasswordValid() should validate %q (length=%d): %v", tc.password, len(tc.password), err) + t.Fatalf("service.IsPasswordValid() should validate %q (length=%d): %v", tc.password, len(tc.password), err) } if !tc.shouldValidate && err == nil { - t.Fatalf("IsPasswordValid() should reject %q (length=%d)", tc.password, len(tc.password)) + t.Fatalf("service.IsPasswordValid() should reject %q (length=%d)", tc.password, len(tc.password)) } } } @@ -101,7 +102,7 @@ func TestAuthentication_GeneratePasswordHashWithValidPasswords(t *testing.T) { } for _, password := range validPasswords { - hash, err := GeneratePasswordHash(password) + hash, err := service.GeneratePasswordHash(password) if err != nil { t.Fatalf("GeneratePasswordHash() failed for valid password %q: %v", password, err) } @@ -128,14 +129,14 @@ func TestAuthentication_IsPasswordValidWithUnicodeCharacters(t *testing.T) { for _, tc := range testCases { // Just verify the function doesn't panic or error unexpectedly - _ = IsPasswordValid(tc.password) + _ = service.IsPasswordValid(tc.password) } } func TestAuthentication_GeneratePasswordHashIsConsistent(t *testing.T) { password := "secret123" - hash1, err1 := GeneratePasswordHash(password) - hash2, err2 := GeneratePasswordHash(password) + hash1, err1 := service.GeneratePasswordHash(password) + hash2, err2 := service.GeneratePasswordHash(password) if err1 != nil || err2 != nil { t.Fatalf("GeneratePasswordHash() returned error: err1=%v, err2=%v", err1, err2) diff --git a/internal/core/service/shared_data.go b/internal/core/service/shared_data.go index 789d18e..a71af12 100644 --- a/internal/core/service/shared_data.go +++ b/internal/core/service/shared_data.go @@ -4,8 +4,8 @@ import ( "fmt" "time" - "keep-it-up/internal/constant" "keep-it-up/internal/core/model" + "keep-it-up/internal/infrastructure/constant" "keep-it-up/internal/infrastructure/database" ) diff --git a/internal/constant/constant.go b/internal/infrastructure/constant/constant.go similarity index 100% rename from internal/constant/constant.go rename to internal/infrastructure/constant/constant.go diff --git a/internal/infrastructure/driver/cli.go b/internal/infrastructure/driver/cliadapter/cli.go similarity index 97% rename from internal/infrastructure/driver/cli.go rename to internal/infrastructure/driver/cliadapter/cli.go index bfce63b..11ed22d 100644 --- a/internal/infrastructure/driver/cli.go +++ b/internal/infrastructure/driver/cliadapter/cli.go @@ -1,4 +1,4 @@ -package driver +package cliadapter import ( "context" @@ -7,7 +7,7 @@ import ( "io" "strconv" - driverport "keep-it-up/internal/core/interface/driver" + "keep-it-up/internal/core/port" "keep-it-up/internal/core/service" ) @@ -56,12 +56,12 @@ Nouns: // composition — this file makes the conservative choice since it assumes // nothing about how internal/application/usecase structures its types. type Deps struct { - Games driverport.GameManagement - Access driverport.AccessManagement - Players driverport.PlayerManagement - Auth driverport.Authentication - Data driverport.DataFetching - Commands driverport.GameCommands + Games port.GameManagement + Access port.AccessManagement + Players port.PlayerManagement + Auth port.Authentication + Data port.DataFetching + Commands port.GameCommands Stdout io.Writer Stderr io.Writer diff --git a/internal/infrastructure/driver/cli_test.go b/internal/infrastructure/driver/cliadapter/cli_test.go similarity index 91% rename from internal/infrastructure/driver/cli_test.go rename to internal/infrastructure/driver/cliadapter/cli_test.go index 8405a4d..6d95b94 100644 --- a/internal/infrastructure/driver/cli_test.go +++ b/internal/infrastructure/driver/cliadapter/cli_test.go @@ -1,4 +1,4 @@ -package driver_test +package cliadapter_test import ( "bytes" @@ -8,7 +8,7 @@ import ( "testing" "keep-it-up/internal/infrastructure/database" - "keep-it-up/internal/infrastructure/driver" + "keep-it-up/internal/infrastructure/driver/cliadapter" ) // --- Mocks --- @@ -54,7 +54,7 @@ func TestCLI_Run(t *testing.T) { tests := []struct { name string args []string - setupMocks func(d *driver.Deps) + setupMocks func(d *cliadapter.Deps) expectedErr error expectedStdout string expectedStderr string @@ -63,7 +63,7 @@ func TestCLI_Run(t *testing.T) { { name: "no arguments returns ErrNoCommand", args: []string{}, - expectedErr: driver.ErrNoCommand, + expectedErr: cliadapter.ErrNoCommand, expectedStderr: "Usage:", }, { @@ -75,7 +75,7 @@ func TestCLI_Run(t *testing.T) { { name: "unknown noun returns ErrUnknownCommand", args: []string{"unknown-noun"}, - expectedErr: driver.ErrUnknownCommand, + expectedErr: cliadapter.ErrUnknownCommand, expectedStderr: "Usage:", }, @@ -83,12 +83,12 @@ func TestCLI_Run(t *testing.T) { { name: "game without verb returns ErrNoSubcommand", args: []string{"game"}, - expectedErr: driver.ErrNoSubcommand, + expectedErr: cliadapter.ErrNoSubcommand, }, { name: "game add success", args: []string{"game", "add", "MyGame"}, - setupMocks: func(d *driver.Deps) { + setupMocks: func(d *cliadapter.Deps) { d.Games = &mockGames{ AddGameFunc: func(ctx context.Context, name string) (database.Game, error) { if name != "MyGame" { @@ -104,7 +104,7 @@ func TestCLI_Run(t *testing.T) { { name: "game add fails wrong arg count", args: []string{"game", "add", "Too", "Many"}, - expectedErr: driver.ErrWrongArgCount, + expectedErr: cliadapter.ErrWrongArgCount, }, { name: "game update invalid id", @@ -117,7 +117,7 @@ func TestCLI_Run(t *testing.T) { { name: "session save success", args: []string{"session", "save", "10", "20", "300"}, - setupMocks: func(d *driver.Deps) { + setupMocks: func(d *cliadapter.Deps) { d.Commands = &mockCommands{ SaveGameFunc: func(ctx context.Context, gameID, playerID int64, duration int64) error { if gameID != 10 || playerID != 20 { @@ -141,7 +141,7 @@ func TestCLI_Run(t *testing.T) { { name: "session resume business error", args: []string{"session", "resume", "1", "2"}, - setupMocks: func(d *driver.Deps) { + setupMocks: func(d *cliadapter.Deps) { d.Commands = &mockCommands{ ResumeGameFunc: func(ctx context.Context, gameID, playerID int64) error { return dummyErr @@ -157,7 +157,7 @@ func TestCLI_Run(t *testing.T) { var stdout, stderr bytes.Buffer // Construct dependencies - deps := driver.Deps{ + deps := cliadapter.Deps{ Stdout: &stdout, Stderr: &stderr, } @@ -167,7 +167,7 @@ func TestCLI_Run(t *testing.T) { tt.setupMocks(&deps) } - cliApp := driver.New(deps) + cliApp := cliadapter.New(deps) err := cliApp.Run(ctx, tt.args) // Assert Errors From 133a8f9845704f511a74ce61d24c857f2041c752 Mon Sep 17 00:00:00 2001 From: Fellipe Leonardo <112525075+neoRandom@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:39:45 -0300 Subject: [PATCH 3/8] chore: clean overall project, nothing much --- README.md | 4 +- cmd/manager/main.go | 2 +- cmd/server/main.go | 2 +- .../application/usecase/access_management.go | 8 +- .../application/usecase/authentication.go | 2 +- .../usecase/game_management_test.go | 16 ++-- .../application/usecase/player_management.go | 4 +- internal/core/model/model.go | 7 +- internal/core/port/driver.go | 3 + .../infrastructure/driver/cliadapter/cli.go | 94 +++++++++---------- .../driver/cliadapter/cli_test.go | 50 +++++----- 11 files changed, 98 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 0e2651c..d97544e 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ Nouns: auth validate-passwd hash-passwd check-passwd - data games + fetch games shared interactions - session save + command save resume pause ``` diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 3588845..b3a3bf5 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -58,7 +58,7 @@ func run() int { Access: usecase.NewAccessManagement(q), Players: usecase.NewPlayerManagement(q, auth), Auth: auth, - Data: usecase.NewDataFetching(q), + Fetch: usecase.NewDataFetching(q), Commands: usecase.NewGameCommands(q, &driven.DefaultTimeProvider{}), Stdout: os.Stdout, Stderr: os.Stderr, diff --git a/cmd/server/main.go b/cmd/server/main.go index de32d18..256b986 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,7 +10,7 @@ func main() { e.GET("/", func(ctx echo.Context) error { return ctx.JSON( - http.StatusNoContent, + http.StatusNoContent, map[string]string{"message": "Not implemented yet!"}, ) }) diff --git a/internal/application/usecase/access_management.go b/internal/application/usecase/access_management.go index 70d82ce..9799e38 100644 --- a/internal/application/usecase/access_management.go +++ b/internal/application/usecase/access_management.go @@ -29,11 +29,11 @@ func (uc *AccessManagement) GrantPlayerAccess( if playerId < 1 { return fmt.Errorf("invalid player ID: %d", playerId) } - + _, err := uc.q.GrantPlayerAccess( ctx, database.GrantPlayerAccessParams{ - GameID: gameId, + GameID: gameId, PlayerID: playerId, }, ) @@ -55,11 +55,11 @@ func (uc *AccessManagement) RevokePlayerAccess( if playerId < 1 { return fmt.Errorf("invalid player ID: %d", playerId) } - + return uc.q.RevokePlayerAccess( ctx, database.RevokePlayerAccessParams{ - GameID: gameId, + GameID: gameId, PlayerID: playerId, }, ) diff --git a/internal/application/usecase/authentication.go b/internal/application/usecase/authentication.go index b504326..47b2a39 100644 --- a/internal/application/usecase/authentication.go +++ b/internal/application/usecase/authentication.go @@ -50,6 +50,6 @@ func (uc *Authentication) CheckPlayerPassword(ctx context.Context, username stri func (uc *Authentication) LoginPlayer( ctx context.Context, username string, password string, ) (model.AuthResult, error) { - // TODO: Implement + // TODO: Needed for web-based authentication return model.AuthResult{}, nil } diff --git a/internal/application/usecase/game_management_test.go b/internal/application/usecase/game_management_test.go index 5abbea4..bc07d8a 100644 --- a/internal/application/usecase/game_management_test.go +++ b/internal/application/usecase/game_management_test.go @@ -204,17 +204,17 @@ func TestGameManagement_RejectsInvalidGameNameFormats(t *testing.T) { name string valid bool }{ - {"AB", false}, // too short - {"ABC", true}, // exactly 3 chars - minimum - {"ValidGame", true}, // valid - {"Game123", true}, // alphanumeric + {"AB", false}, // too short + {"ABC", true}, // exactly 3 chars - minimum + {"ValidGame", true}, // valid + {"Game123", true}, // alphanumeric {"Game-Name", true}, // contains hyphen {"Game Name", true}, // contains space {"Game@123", true}, // contains special char - {"123", true}, // digits only, 3 chars - {"AB", false}, // 2 chars - below minimum - {"A", false}, // 1 char - too short - {"", false}, // empty + {"123", true}, // digits only, 3 chars + {"AB", false}, // 2 chars - below minimum + {"A", false}, // 1 char - too short + {"", false}, // empty } for _, tc := range testCases { diff --git a/internal/application/usecase/player_management.go b/internal/application/usecase/player_management.go index ef96853..2522864 100644 --- a/internal/application/usecase/player_management.go +++ b/internal/application/usecase/player_management.go @@ -57,7 +57,7 @@ func (uc *PlayerManagement) AddPlayer(ctx context.Context, name string, username } password = strings.TrimSpace(password) - + if password == username { return database.Player{}, errors.New("player password cannot be equal to its username") } @@ -154,7 +154,7 @@ func (uc *PlayerManagement) UpdatePlayerPassword(ctx context.Context, id int64, if err := service.IsPasswordValid(newPassword); err != nil { return err } - + if newPassword == player.Username { return errors.New("new player password cannot be equal to its username") } diff --git a/internal/core/model/model.go b/internal/core/model/model.go index d457b02..6ee9ce1 100644 --- a/internal/core/model/model.go +++ b/internal/core/model/model.go @@ -14,7 +14,8 @@ const ( ) type SharedData struct { - GameID int64 + GameID int64 + // TODO: add `Valid` field - check if deadline was reached already Status SharedDataStatus DeadlineAt *time.Time LastSavedAt *time.Time @@ -22,6 +23,6 @@ type SharedData struct { } type AuthResult struct { - Token string - Player database.Player + Token string + Player database.Player } diff --git a/internal/core/port/driver.go b/internal/core/port/driver.go index 6115cd4..3b98a7d 100644 --- a/internal/core/port/driver.go +++ b/internal/core/port/driver.go @@ -54,6 +54,9 @@ type DataFetching interface { ListInteractions( ctx context.Context, gameId int64, limit int64, ) ([]database.Interaction, error) + // TODO: List Player Interactions + // TODO: First Interaction + // TODO: Last Interaction } type GameCommands interface { diff --git a/internal/infrastructure/driver/cliadapter/cli.go b/internal/infrastructure/driver/cliadapter/cli.go index 11ed22d..9f54602 100644 --- a/internal/infrastructure/driver/cliadapter/cli.go +++ b/internal/infrastructure/driver/cliadapter/cli.go @@ -19,7 +19,7 @@ var ( ErrWrongArgCount = errors.New("wrong number of arguments") ) -const usage = `keepitup --- system management CLI +const usage = `keep-it-up --- system management CLI Usage: keepitup [args...] @@ -38,10 +38,10 @@ Nouns: auth validate-passwd hash-passwd check-passwd - data games + fetch games shared interactions - session save + command save resume pause ` @@ -60,7 +60,7 @@ type Deps struct { Access port.AccessManagement Players port.PlayerManagement Auth port.Authentication - Data port.DataFetching + Fetch port.DataFetching Commands port.GameCommands Stdout io.Writer @@ -93,9 +93,9 @@ func (c *CLI) Run(ctx context.Context, args []string) error { case "auth": return c.runAuth(ctx, rest) case "data": - return c.runData(ctx, rest) - case "session": - return c.runSession(ctx, rest) + return c.runFetch(ctx, rest) + case "command": + return c.runCommand(ctx, rest) case "help", "-h", "--help": fmt.Fprint(c.d.Stdout, usage) return nil @@ -119,7 +119,7 @@ func wrongArgs(cmd, usage string) error { return fmt.Errorf("%s: %w (usage: %s)", cmd, ErrWrongArgCount, usage) } -// --- game: driverport.GameManagement --------------------------------------- +// --- game: port.GameManagement --------------------------------------- func (c *CLI) runGame(ctx context.Context, args []string) error { if len(args) == 0 { @@ -171,7 +171,7 @@ func (c *CLI) runGame(ctx context.Context, args []string) error { } } -// --- access: driverport.AccessManagement ----------------------------------- +// --- access: port.AccessManagement ----------------------------------- func (c *CLI) runAccess(ctx context.Context, args []string) error { if len(args) == 0 { @@ -220,7 +220,7 @@ func (c *CLI) runAccess(ctx context.Context, args []string) error { } } -// --- player: driverport.PlayerManagement ----------------------------------- +// --- player: port.PlayerManagement ----------------------------------- func (c *CLI) runPlayer(ctx context.Context, args []string) error { if len(args) == 0 { @@ -300,7 +300,7 @@ func (c *CLI) runPlayer(ctx context.Context, args []string) error { } } -// --- auth: driverport.Authentication, minus LoginPlayer -------------------- +// --- auth: port.Authentication, minus LoginPlayer -------------------- func (c *CLI) runAuth(ctx context.Context, args []string) error { if len(args) == 0 { @@ -345,9 +345,9 @@ func (c *CLI) runAuth(ctx context.Context, args []string) error { } } -// --- data: driverport.DataFetching ------------------------------------------ +// --- data: port.DataFetching ------------------------------------------ -func (c *CLI) runData(ctx context.Context, args []string) error { +func (c *CLI) runFetch(ctx context.Context, args []string) error { if len(args) == 0 { return fmt.Errorf("data: %w", ErrNoSubcommand) } @@ -355,15 +355,15 @@ func (c *CLI) runData(ctx context.Context, args []string) error { switch verb { case "games": if len(rest) != 1 { - return wrongArgs("data games", "data games ") + return wrongArgs("fetch games", "fetch games ") } playerID, err := parseID(rest[0]) if err != nil { - return fmt.Errorf("data games: %w", err) + return fmt.Errorf("fetch games: %w", err) } - games, err := c.d.Data.ListPlayerGames(ctx, playerID) + games, err := c.d.Fetch.ListPlayerGames(ctx, playerID) if err != nil { - return fmt.Errorf("data games: %w", err) + return fmt.Errorf("fetch games: %w", err) } for _, g := range games { fmt.Fprintf(c.d.Stdout, "%+v\n", g) @@ -372,34 +372,34 @@ func (c *CLI) runData(ctx context.Context, args []string) error { case "shared": if len(rest) != 1 { - return wrongArgs("data shared", "data shared ") + return wrongArgs("fetch shared", "fetch shared ") } gameID, err := parseID(rest[0]) if err != nil { - return fmt.Errorf("data shared: %w", err) + return fmt.Errorf("fetch shared: %w", err) } - shared, err := c.d.Data.GetSharedData(ctx, gameID) + shared, err := c.d.Fetch.GetSharedData(ctx, gameID) if err != nil { - return fmt.Errorf("data shared: %w", err) + return fmt.Errorf("fetch shared: %w", err) } fmt.Fprintf(c.d.Stdout, "%+v\n", shared) return nil case "interactions": if len(rest) != 2 { - return wrongArgs("data interactions", "data interactions ") + return wrongArgs("fetch interactions", "fetch interactions ") } gameID, err := parseID(rest[0]) if err != nil { - return fmt.Errorf("data interactions: %w", err) + return fmt.Errorf("fetch interactions: %w", err) } limit, err := strconv.ParseInt(rest[1], 10, 64) if err != nil { - return fmt.Errorf("data interactions: invalid limit %q: %w", rest[1], err) + return fmt.Errorf("fetch interactions: invalid limit %q: %w", rest[1], err) } - interactions, err := c.d.Data.ListInteractions(ctx, gameID, limit) + interactions, err := c.d.Fetch.ListInteractions(ctx, gameID, limit) if err != nil { - return fmt.Errorf("data interactions: %w", err) + return fmt.Errorf("fetch interactions: %w", err) } for _, i := range interactions { fmt.Fprintf(c.d.Stdout, "%+v\n", i) @@ -407,81 +407,81 @@ func (c *CLI) runData(ctx context.Context, args []string) error { return nil default: - return fmt.Errorf("data %s: %w", verb, ErrUnknownSubcommand) + return fmt.Errorf("fetch %s: %w", verb, ErrUnknownSubcommand) } } -// --- session: driverport.GameCommands --------------------------------------- +// --- command: port.GameCommands --------------------------------------- // -// Named "session" rather than "game-cmd" to keep the noun namespace +// Named "command" rather than "game-cmd" to keep the noun namespace // readable; it maps 1:1 onto GameCommands (save/resume/pause) and doesn't // overlap with the "game" noun (GameManagement: add/update/delete). -func (c *CLI) runSession(ctx context.Context, args []string) error { +func (c *CLI) runCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return fmt.Errorf("session: %w", ErrNoSubcommand) + return fmt.Errorf("command: %w", ErrNoSubcommand) } verb, rest := args[0], args[1:] switch verb { case "save": if len(rest) != 3 { - return wrongArgs("session save", "session save ") + return wrongArgs("command save", "command save ") } gameID, err := parseID(rest[0]) if err != nil { - return fmt.Errorf("session save: %w", err) + return fmt.Errorf("command save: %w", err) } playerID, err := parseID(rest[1]) if err != nil { - return fmt.Errorf("session save: %w", err) + return fmt.Errorf("command save: %w", err) } duration, err := strconv.ParseInt(rest[2], 10, 64) if err != nil { - return fmt.Errorf("session save: invalid timestamp %q: %w", rest[2], err) + return fmt.Errorf("command save: invalid timestamp %q: %w", rest[2], err) } if err := c.d.Commands.SaveGame(ctx, gameID, playerID, duration); err != nil { - return fmt.Errorf("session save: %w", err) + return fmt.Errorf("command save: %w", err) } fmt.Fprintf(c.d.Stdout, "game %d saved by player %d for %d min\n", gameID, playerID, duration) return nil case "resume": if len(rest) != 2 { - return wrongArgs("session resume", "session resume ") + return wrongArgs("command resume", "command resume ") } gameID, err := parseID(rest[0]) if err != nil { - return fmt.Errorf("session resume: %w", err) + return fmt.Errorf("command resume: %w", err) } playerID, err := parseID(rest[1]) if err != nil { - return fmt.Errorf("session resume: %w", err) + return fmt.Errorf("command resume: %w", err) } if err := c.d.Commands.ResumeGame(ctx, gameID, playerID); err != nil { - return fmt.Errorf("session resume: %w", err) + return fmt.Errorf("command resume: %w", err) } - fmt.Fprintf(c.d.Stdout, "game %d resumed for player %d\n", gameID, playerID) + fmt.Fprintf(c.d.Stdout, "game %d resumed by player %d\n", gameID, playerID) return nil case "pause": if len(rest) != 2 { - return wrongArgs("session pause", "session pause ") + return wrongArgs("command pause", "command pause ") } gameID, err := parseID(rest[0]) if err != nil { - return fmt.Errorf("session pause: %w", err) + return fmt.Errorf("command pause: %w", err) } playerID, err := parseID(rest[1]) if err != nil { - return fmt.Errorf("session pause: %w", err) + return fmt.Errorf("command pause: %w", err) } if err := c.d.Commands.PauseGame(ctx, gameID, playerID); err != nil { - return fmt.Errorf("session pause: %w", err) + return fmt.Errorf("command pause: %w", err) } - fmt.Fprintf(c.d.Stdout, "game %d paused for player %d\n", gameID, playerID) + fmt.Fprintf(c.d.Stdout, "game %d paused by player %d\n", gameID, playerID) return nil default: - return fmt.Errorf("session %s: %w", verb, ErrUnknownSubcommand) + return fmt.Errorf("command %s: %w", verb, ErrUnknownSubcommand) } } diff --git a/internal/infrastructure/driver/cliadapter/cli_test.go b/internal/infrastructure/driver/cliadapter/cli_test.go index 6d95b94..c51ac81 100644 --- a/internal/infrastructure/driver/cliadapter/cli_test.go +++ b/internal/infrastructure/driver/cliadapter/cli_test.go @@ -61,28 +61,28 @@ func TestCLI_Run(t *testing.T) { }{ // Basic CLI checks { - name: "no arguments returns ErrNoCommand", - args: []string{}, - expectedErr: cliadapter.ErrNoCommand, - expectedStderr: "Usage:", + name: "no arguments returns ErrNoCommand", + args: []string{}, + expectedErr: cliadapter.ErrNoCommand, + expectedStderr: "Usage:", }, { - name: "help flag returns usage on stdout", - args: []string{"--help"}, - expectedErr: nil, + name: "help flag returns usage on stdout", + args: []string{"--help"}, + expectedErr: nil, expectedStdout: "Usage:", }, { - name: "unknown noun returns ErrUnknownCommand", - args: []string{"unknown-noun"}, - expectedErr: cliadapter.ErrUnknownCommand, + name: "unknown noun returns ErrUnknownCommand", + args: []string{"unknown-noun"}, + expectedErr: cliadapter.ErrUnknownCommand, expectedStderr: "Usage:", }, // Game subcommand tests { - name: "game without verb returns ErrNoSubcommand", - args: []string{"game"}, + name: "game without verb returns ErrNoSubcommand", + args: []string{"game"}, expectedErr: cliadapter.ErrNoSubcommand, }, { @@ -94,29 +94,29 @@ func TestCLI_Run(t *testing.T) { if name != "MyGame" { t.Errorf("expected MyGame, got %s", name) } - return database.Game{ID: 42, Name: "MyGame"}, nil + return database.Game{ID: 42, Name: "MyGame"}, nil }, } }, expectedErr: nil, - expectedStdout: "{ID:42 Name:MyGame", + expectedStdout: "{ID:42 Name:MyGame", }, { - name: "game add fails wrong arg count", - args: []string{"game", "add", "Too", "Many"}, + name: "game add fails wrong arg count", + args: []string{"game", "add", "Too", "Many"}, expectedErr: cliadapter.ErrWrongArgCount, }, { name: "game update invalid id", args: []string{"game", "update", "not-an-id", "NewName"}, // Will be wrapped in parseID error, so we just check if it returns an error - expectedErr: errors.New("invalid id"), + expectedErr: errors.New("invalid id"), }, - // Session subcommand tests + // Command subcommand tests { - name: "session save success", - args: []string{"session", "save", "10", "20", "300"}, + name: "command save success", + args: []string{"command", "save", "10", "20", "300"}, setupMocks: func(d *cliadapter.Deps) { d.Commands = &mockCommands{ SaveGameFunc: func(ctx context.Context, gameID, playerID int64, duration int64) error { @@ -134,13 +134,13 @@ func TestCLI_Run(t *testing.T) { expectedStdout: "game 10 saved by player 20 for 300 min\n", }, { - name: "session save with invalid timestamp", - args: []string{"session", "save", "10", "20", "bad-time"}, + name: "command save with invalid timestamp", + args: []string{"command", "save", "10", "20", "bad-time"}, expectedErr: errors.New("invalid timestamp"), }, { - name: "session resume business error", - args: []string{"session", "resume", "1", "2"}, + name: "command resume business error", + args: []string{"command", "resume", "1", "2"}, setupMocks: func(d *cliadapter.Deps) { d.Commands = &mockCommands{ ResumeGameFunc: func(ctx context.Context, gameID, playerID int64) error { @@ -198,4 +198,4 @@ func TestCLI_Run(t *testing.T) { } }) } -} \ No newline at end of file +} From a621e310dc460ff87ba2972d14da0681305480cf Mon Sep 17 00:00:00 2001 From: Fellipe Leonardo <112525075+neoRandom@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:50:41 -0300 Subject: [PATCH 4/8] docs: fix massive typo "openai -> openapi" --- api/{openai.yaml => openapi.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename api/{openai.yaml => openapi.yaml} (100%) diff --git a/api/openai.yaml b/api/openapi.yaml similarity index 100% rename from api/openai.yaml rename to api/openapi.yaml From 6b43757f4ff720999735a1d6c83dbbcdf74b54c1 Mon Sep 17 00:00:00 2001 From: Fellipe Leonardo <112525075+neoRandom@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:29:40 -0300 Subject: [PATCH 5/8] docs: update API contract --- README.md | 5 ++++- api/openapi.yaml | 53 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d97544e..931b09c 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,14 @@ Nouns: ## HTTP API ```text +no auth required: POST /login username + password → set cookies + +auth/cookies required: GET /games game access → list accessible games GET /shared game ID → current shared state GET /interactions game ID → latest interactions -POST /save game ID → add save interaction +POST /save game ID + duration in seconds → add save interaction POST /play game ID → start or resume the game POST /pause game ID → pause the game ``` diff --git a/api/openapi.yaml b/api/openapi.yaml index 38db0cc..10788d1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Keep It Up API - version: 1.0.0 + version: 1.1.0 paths: /login: @@ -91,6 +91,12 @@ paths: - sessionCookie: [] parameters: - $ref: '#/components/parameters/GameId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SaveRequest' responses: '204': description: Save interaction added. @@ -110,7 +116,7 @@ paths: - $ref: '#/components/parameters/GameId' responses: '204': - description: Game started or resumed. + description: Game resumed. '401': $ref: '#/components/responses/Unauthorized' '404': @@ -141,6 +147,8 @@ components: type: apiKey in: cookie name: session + schema: + type: string parameters: GameId: @@ -186,14 +194,22 @@ components: SharedData: type: object required: + - gameId - status properties: + gameId: + type: integer + format: int64 status: type: string enum: - not_started - playing - paused + valid: + type: + - boolean + - 'null' deadlineAt: type: - string @@ -213,26 +229,47 @@ components: format: date-time description: The time at which the shared state was last paused. + SaveRequest: + type: object + required: + - duration + properties: + duration: + type: integer + format: int64 + minimum: 1 + description: Duration of the save, in seconds. + Interaction: type: object required: - id - - playerId + - gameId - action - occurredAt properties: id: type: integer format: int64 - playerId: + gameId: type: integer format: int64 + playerId: + type: + - integer + - 'null' + format: int64 action: type: string enum: - - save - - play - - pause + - saved + - paused + - resumed occurredAt: type: string - format: date-time \ No newline at end of file + format: date-time + savedBy: + type: + - integer + - 'null' + format: int64 \ No newline at end of file From d8414551a394991c55e675f47ce25726107fbddd Mon Sep 17 00:00:00 2001 From: Fellipe Leonardo <112525075+neoRandom@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:30:10 -0300 Subject: [PATCH 6/8] feat: add `Valid` field to SharedData struct --- internal/core/model/model.go | 4 ++-- internal/core/service/shared_data.go | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/core/model/model.go b/internal/core/model/model.go index 6ee9ce1..aa67242 100644 --- a/internal/core/model/model.go +++ b/internal/core/model/model.go @@ -14,8 +14,8 @@ const ( ) type SharedData struct { - GameID int64 - // TODO: add `Valid` field - check if deadline was reached already + GameID int64 + Valid *bool Status SharedDataStatus DeadlineAt *time.Time LastSavedAt *time.Time diff --git a/internal/core/service/shared_data.go b/internal/core/service/shared_data.go index a71af12..ac5bcd7 100644 --- a/internal/core/service/shared_data.go +++ b/internal/core/service/shared_data.go @@ -9,6 +9,15 @@ import ( "keep-it-up/internal/infrastructure/database" ) +func ComputeValid(s *model.SharedData, now time.Time) { + if s.DeadlineAt == nil { + s.Valid = nil + return + } + valid := now.Before(*s.DeadlineAt) + s.Valid = &valid +} + func BuildSharedData( gameId int64, interactions []database.Interaction, ) (*model.SharedData, error) { From e1209839039c307422eb982941681b6b97136993 Mon Sep 17 00:00:00 2001 From: Fellipe Leonardo <112525075+neoRandom@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:31:11 -0300 Subject: [PATCH 7/8] chore: add JWT-based auth contracts --- .env.example | 4 +++- internal/application/model/authentication.go | 9 +++++++++ internal/application/usecase/authentication.go | 6 ++++++ internal/core/port/driver.go | 3 +++ 4 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 internal/application/model/authentication.go diff --git a/.env.example b/.env.example index eed7cdf..f845111 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ GOOSE_DRIVER="sqlite3" GOOSE_DBSTRING="./database/main.db" -GOOSE_MIGRATION_DIR="./database/migrations" \ No newline at end of file +GOOSE_MIGRATION_DIR="./database/migrations" + +JWT_SECRET="" diff --git a/internal/application/model/authentication.go b/internal/application/model/authentication.go new file mode 100644 index 0000000..cefaad9 --- /dev/null +++ b/internal/application/model/authentication.go @@ -0,0 +1,9 @@ +package model + +import "github.com/golang-jwt/jwt/v4" + +type JwtPlayerClaims struct { + UserID int `json:"user_id"` + Username string `json:"username"` + jwt.RegisteredClaims +} diff --git a/internal/application/usecase/authentication.go b/internal/application/usecase/authentication.go index 47b2a39..ba89980 100644 --- a/internal/application/usecase/authentication.go +++ b/internal/application/usecase/authentication.go @@ -53,3 +53,9 @@ func (uc *Authentication) LoginPlayer( // TODO: Needed for web-based authentication return model.AuthResult{}, nil } + +func (uc *Authentication) ValidateSession( + ctx context.Context, token string, +) (database.Player, error) { + return database.Player{}, nil +} diff --git a/internal/core/port/driver.go b/internal/core/port/driver.go index 3b98a7d..2d03ff9 100644 --- a/internal/core/port/driver.go +++ b/internal/core/port/driver.go @@ -42,6 +42,9 @@ type Authentication interface { LoginPlayer( ctx context.Context, username string, password string, ) (model.AuthResult, error) + ValidateSession( + ctx context.Context, token string, + ) (database.Player, error) } type DataFetching interface { From 699effc914a59406176ea443baad6a181a5be5a3 Mon Sep 17 00:00:00 2001 From: Fellipe Leonardo <112525075+neoRandom@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:34:07 -0300 Subject: [PATCH 8/8] feat: implement HTTP JWT-based authentication --- .env.example | 1 + cmd/manager/main.go | 5 +- cmd/server/main.go | 116 ++++++++++++- go.mod | 9 +- go.sum | 16 +- internal/application/model/authentication.go | 6 +- .../application/usecase/authentication.go | 56 ++++++- .../usecase/authentication_test.go | 18 +- .../usecase/player_management_test.go | 50 +++--- internal/core/port/driven.go | 9 +- internal/infrastructure/constant/constant.go | 2 + ...e_provider.go => default_time_provider.go} | 0 .../driven/jwt_token_generator.go | 46 ++++++ .../infrastructure/driver/httpadapter/http.go | 154 ++++++++++++++++++ 14 files changed, 419 insertions(+), 69 deletions(-) rename internal/infrastructure/driven/{time_provider.go => default_time_provider.go} (100%) create mode 100644 internal/infrastructure/driven/jwt_token_generator.go create mode 100644 internal/infrastructure/driver/httpadapter/http.go diff --git a/.env.example b/.env.example index f845111..b6fa55a 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ GOOSE_DRIVER="sqlite3" GOOSE_DBSTRING="./database/main.db" GOOSE_MIGRATION_DIR="./database/migrations" +SERVER_ADDRESS=":8080" JWT_SECRET="" diff --git a/cmd/manager/main.go b/cmd/manager/main.go index b3a3bf5..6e7177c 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -10,6 +10,7 @@ import ( "syscall" "keep-it-up/internal/application/usecase" + "keep-it-up/internal/infrastructure/constant" "keep-it-up/internal/infrastructure/database" "keep-it-up/internal/infrastructure/driven" "keep-it-up/internal/infrastructure/driver/cliadapter" @@ -27,7 +28,7 @@ func run() int { defer stop() // --- Driven side: infrastructure dependencies --------------------- - util.LoadEnv(".env") + util.LoadEnv(constant.EnvFilename) dbString, err := filepath.Abs(os.Getenv("GOOSE_DBSTRING")) if err != nil { @@ -52,7 +53,7 @@ func run() int { q := database.New(sqlDB) // --- Application side: use cases implementing the driver ports ---- - auth := usecase.NewAuthentication(q) + auth := usecase.NewAuthentication(q, nil) deps := cliadapter.Deps{ Games: usecase.NewGameManagement(q), Access: usecase.NewAccessManagement(q), diff --git a/cmd/server/main.go b/cmd/server/main.go index 256b986..d3aea24 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,21 +1,119 @@ package main import ( - "github.com/labstack/echo" + "context" + "database/sql" + "errors" + "fmt" + "keep-it-up/internal/application/usecase" + "keep-it-up/internal/infrastructure/constant" + "keep-it-up/internal/infrastructure/database" + "keep-it-up/internal/infrastructure/driven" + "keep-it-up/internal/infrastructure/driver/httpadapter" + "keep-it-up/internal/infrastructure/util" + "log" "net/http" + "os" + "os/signal" + "path/filepath" + "sync" + + _ "modernc.org/sqlite" ) func main() { - e := echo.New() + os.Exit(run()) +} + +func run() int { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + util.LoadEnv(constant.EnvFilename) + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + log.Println("failed to get JWT secret") + return 1 + } + + addr := os.Getenv("SERVER_ADDRESS") + if addr == "" { + log.Println("failed to get server address") + } + + dbString, err := filepath.Abs(os.Getenv("GOOSE_DBSTRING")) + if err != nil { + fmt.Printf("failed to get dbstring absolute path: %v\n", err) + return 1 + } + + sqlDB, err := sql.Open("sqlite", fmt.Sprintf( + "file:%s?mode=rw", + dbString, + )) + if err != nil { + fmt.Fprintf(os.Stderr, "open database: %v\n", err) + return 1 + } + defer sqlDB.Close() + if err := sqlDB.PingContext(ctx); err != nil { + fmt.Fprintf(os.Stderr, "connect database: %v\n", err) + return 1 + } + + q := database.New(sqlDB) + + timeProvider := &driven.DefaultTimeProvider{} + deps := httpadapter.Deps{ + Auth: usecase.NewAuthentication( + q, + &driven.JwtTokenGenerator{ + JwtSecret: jwtSecret, + TimeProvider: timeProvider, + }, + ), + } + + adapter := httpadapter.New( + addr, + jwtSecret, + timeProvider, + deps, + ) - e.GET("/", func(ctx echo.Context) error { - return ctx.JSON( - http.StatusNoContent, - map[string]string{"message": "Not implemented yet!"}, - ) + var wg sync.WaitGroup + errCh := make(chan error, 1) + + wg.Go(func() { + log.Printf("Starting server on %s...\n", addr) + if err := adapter.Run(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { + wrappedErr := fmt.Errorf( + "server failed to start or stopped unexpectedly: %w", + err, + ) + errCh <- wrappedErr + stop() + return + } + + log.Println("server stopped gracefully") }) - if err := e.Start(":8080"); err != nil { - e.Logger.Error("failed to start server", "error", err) + <-ctx.Done() + log.Printf("Shutdown requested: %v", ctx.Err()) + + wg.Wait() + close(errCh) + + select { + case err := <-errCh: + if err != nil { + log.Printf("shutdown after server failure: %v", err) + return 1 + } + default: } + + return 0 } diff --git a/go.mod b/go.mod index a8b2452..3ff4104 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,9 @@ tool ( ) require ( - github.com/labstack/echo v3.3.10+incompatible + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/labstack/echo-jwt/v5 v5.0.2 + github.com/labstack/echo/v5 v5.2.1 github.com/pressly/goose/v3 v3.27.3 golang.org/x/crypto v0.54.0 modernc.org/sqlite v1.54.0 @@ -47,8 +49,6 @@ require ( github.com/joho/godotenv v1.5.1 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/klauspost/compress v1.19.1 // indirect - github.com/labstack/gommon v0.5.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.23 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/xflag v0.1.0 // indirect @@ -76,8 +76,6 @@ require ( github.com/sqlc-dev/sqlc v1.31.1 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tursodatabase/libsql-client-go v0.0.0-20260528064733-9d5d30a29a60 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect github.com/vertica/vertica-sql-go v1.3.8 // indirect github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect @@ -95,6 +93,7 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea // indirect google.golang.org/grpc v1.82.1 // indirect diff --git a/go.sum b/go.sum index 8eb708c..5f9e441 100644 --- a/go.sum +++ b/go.sum @@ -145,12 +145,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg= -github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= -github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= -github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/labstack/echo-jwt/v5 v5.0.2 h1:ECmHEjwbR30fv90LvpQ0XZbEJWhCF/c3Zp8ug/iVrCE= +github.com/labstack/echo-jwt/v5 v5.0.2/go.mod h1:jNxekWlI4+M+UoV8dozKwIHN9MJ2psUR+U1B0nEutIg= +github.com/labstack/echo/v5 v5.2.1 h1:TzpIksY6zLMzV0T0ycYbvTEoj9w6o6AcL5twg182VTY= +github.com/labstack/echo/v5 v5.2.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -228,10 +226,6 @@ github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbw github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tursodatabase/libsql-client-go v0.0.0-20260528064733-9d5d30a29a60 h1:TfQEwhr0Q9t+Bgs0TNk2eHZ9EGD107Mimic0kcoGS1M= github.com/tursodatabase/libsql-client-go v0.0.0-20260528064733-9d5d30a29a60/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vertica/vertica-sql-go v1.3.8 h1:FomjkM3cam9yE6zSic31flNWPLdsZbYGK9ihlLtbF1Y= github.com/vertica/vertica-sql-go v1.3.8/go.mod h1:c4OZ8lq1Ztc18w8a0nG+dzQh69BzJRcKN2LZOnYbERI= github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= @@ -322,6 +316,8 @@ 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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/internal/application/model/authentication.go b/internal/application/model/authentication.go index cefaad9..c29ae07 100644 --- a/internal/application/model/authentication.go +++ b/internal/application/model/authentication.go @@ -1,9 +1,9 @@ package model -import "github.com/golang-jwt/jwt/v4" +import "github.com/golang-jwt/jwt/v5" type JwtPlayerClaims struct { - UserID int `json:"user_id"` - Username string `json:"username"` jwt.RegisteredClaims + UserID int64 `json:"user_id"` + Username string `json:"username"` } diff --git a/internal/application/usecase/authentication.go b/internal/application/usecase/authentication.go index ba89980..21175c1 100644 --- a/internal/application/usecase/authentication.go +++ b/internal/application/usecase/authentication.go @@ -5,18 +5,28 @@ import ( "errors" "fmt" "keep-it-up/internal/core/model" + "keep-it-up/internal/core/port" "keep-it-up/internal/core/service" "keep-it-up/internal/infrastructure/database" + "strings" "golang.org/x/crypto/bcrypt" ) +var ( + ErrUnauthorized = errors.New("player not authorized") + ErrBadRequest = errors.New("missing username or password") +) + type Authentication struct { - q *database.Queries + q *database.Queries + tg port.TokenGenerator } -func NewAuthentication(q *database.Queries) *Authentication { - return &Authentication{q: q} +func NewAuthentication(q *database.Queries, tg port.TokenGenerator) *Authentication { + return &Authentication{ + q: q, tg: tg, + } } func (uc *Authentication) CheckPlayerPassword(ctx context.Context, username string, password string) (bool, error) { @@ -50,8 +60,44 @@ func (uc *Authentication) CheckPlayerPassword(ctx context.Context, username stri func (uc *Authentication) LoginPlayer( ctx context.Context, username string, password string, ) (model.AuthResult, error) { - // TODO: Needed for web-based authentication - return model.AuthResult{}, nil + if uc.q == nil { + return model.AuthResult{}, fmt.Errorf("database queries are not initialized") + } + + if uc.tg == nil { + return model.AuthResult{}, fmt.Errorf("token generator is not initialized") + } + + if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" { + return model.AuthResult{}, ErrBadRequest + } + + correct, err := uc.CheckPlayerPassword(ctx, username, password) + if err != nil { + return model.AuthResult{}, fmt.Errorf( + "failed to check if password is correct: %w", err, + ) + } + if !correct { + return model.AuthResult{}, ErrUnauthorized + } + + player, err := uc.q.GetPlayerByUsername(ctx, username) + if err != nil { + return model.AuthResult{}, fmt.Errorf( + "failed to get player by username: %w", err, + ) + } + + token, err := uc.tg.GenerateToken(player) + if err != nil { + return model.AuthResult{}, fmt.Errorf("failed to generate token: %w", err) + } + + return model.AuthResult{ + Token: token, + Player: player, + }, nil } func (uc *Authentication) ValidateSession( diff --git a/internal/application/usecase/authentication_test.go b/internal/application/usecase/authentication_test.go index 07dd7e8..9d38762 100644 --- a/internal/application/usecase/authentication_test.go +++ b/internal/application/usecase/authentication_test.go @@ -12,7 +12,7 @@ import ( func TestAuthentication_CheckPlayerPasswordUsesPasswordRuleValidation(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - auth := NewAuthentication(queries) + auth := NewAuthentication(queries, nil) hash, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost) if err != nil { @@ -42,7 +42,7 @@ func TestAuthentication_CheckPlayerPasswordUsesPasswordRuleValidation(t *testing func TestAuthentication_CheckPlayerPassword(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - auth := NewAuthentication(queries) + auth := NewAuthentication(queries, nil) hash, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost) if err != nil { @@ -70,7 +70,7 @@ func TestAuthentication_CheckPlayerPassword(t *testing.T) { func TestAuthentication_CheckPlayerPasswordRejectsWrongPassword(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - auth := NewAuthentication(queries) + auth := NewAuthentication(queries, nil) hash, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost) if err != nil { @@ -97,7 +97,7 @@ func TestAuthentication_CheckPlayerPasswordRejectsWrongPassword(t *testing.T) { func TestAuthentication_CheckPlayerPasswordRejectsUnknownUser(t *testing.T) { ctx := context.Background() - auth := NewAuthentication(newTestDB(t)) + auth := NewAuthentication(newTestDB(t), nil) ok, err := auth.CheckPlayerPassword(ctx, "ghost", "secret123") if err == nil { @@ -111,7 +111,7 @@ func TestAuthentication_CheckPlayerPasswordRejectsUnknownUser(t *testing.T) { func TestAuthentication_CheckPlayerPasswordRejectsInvalidInput(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - auth := NewAuthentication(queries) + auth := NewAuthentication(queries, nil) if _, err := auth.CheckPlayerPassword(ctx, "a", "secret123"); err == nil { t.Fatal("CheckPlayerPassword() accepted a short username") @@ -125,7 +125,7 @@ func TestAuthentication_CheckPlayerPasswordRejectsInvalidInput(t *testing.T) { t.Fatal("CheckPlayerPassword() accepted a password equal to the username") } - authNil := NewAuthentication(nil) + authNil := NewAuthentication(nil, nil) if _, err := authNil.CheckPlayerPassword(ctx, "alice", "secret123"); err == nil { t.Fatal("CheckPlayerPassword() accepted nil database queries") } @@ -134,7 +134,7 @@ func TestAuthentication_CheckPlayerPasswordRejectsInvalidInput(t *testing.T) { func TestAuthentication_CheckPlayerPasswordRejectsMalformedHash(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - auth := NewAuthentication(queries) + auth := NewAuthentication(queries, nil) _, err := queries.CreatePlayer(ctx, database.CreatePlayerParams{ Name: "Alice", @@ -156,7 +156,7 @@ func TestAuthentication_CheckPlayerPasswordRejectsMalformedHash(t *testing.T) { func TestAuthentication_CheckPlayerPasswordWithCancelledContext(t *testing.T) { queries := newTestDB(t) - auth := NewAuthentication(queries) + auth := NewAuthentication(queries, nil) // Create a cancelled context ctx, cancel := context.WithCancel(context.Background()) @@ -171,7 +171,7 @@ func TestAuthentication_CheckPlayerPasswordWithCancelledContext(t *testing.T) { func TestAuthentication_CheckPlayerPasswordWithTrimmablePassword(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - auth := NewAuthentication(queries) + auth := NewAuthentication(queries, nil) hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost) _, err := queries.CreatePlayer(ctx, database.CreatePlayerParams{ diff --git a/internal/application/usecase/player_management_test.go b/internal/application/usecase/player_management_test.go index d07b0d3..9cb54ad 100644 --- a/internal/application/usecase/player_management_test.go +++ b/internal/application/usecase/player_management_test.go @@ -9,7 +9,7 @@ import ( func TestPlayerManagement_AddPlayer(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, err := uc.AddPlayer(ctx, "Alice", "alice", "secret123") if err != nil { @@ -23,7 +23,7 @@ func TestPlayerManagement_AddPlayer(t *testing.T) { func TestPlayerManagement_UpdatePlayerName(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, err := uc.AddPlayer(ctx, "Alice", "alice", "secret123") if err != nil { @@ -46,7 +46,7 @@ func TestPlayerManagement_UpdatePlayerName(t *testing.T) { func TestPlayerManagement_UpdatePlayerPassword(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, err := uc.AddPlayer(ctx, "Alice", "alice", "secret123") if err != nil { @@ -69,7 +69,7 @@ func TestPlayerManagement_UpdatePlayerPassword(t *testing.T) { func TestPlayerManagement_RejectsInvalidPlayerInput(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) if _, err := uc.AddPlayer(ctx, "A", "alice", "secret123"); err == nil { t.Fatal("AddPlayer() accepted short name") @@ -99,7 +99,7 @@ func TestPlayerManagement_RejectsInvalidPlayerInput(t *testing.T) { func TestPlayerManagement_RejectsWhitespaceInNamesAndUsernames(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) if _, err := uc.AddPlayer(ctx, "Alice Smith", "alice", "secret123"); err != nil { t.Fatal("AddPlayer() rejected a name containing whitespace") @@ -112,7 +112,7 @@ func TestPlayerManagement_RejectsWhitespaceInNamesAndUsernames(t *testing.T) { func TestPlayerManagement_TrimPasswordWhitespaceBeforeStorage(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) for _, tc := range []struct { name string @@ -136,7 +136,7 @@ func TestPlayerManagement_TrimPasswordWhitespaceBeforeStorage(t *testing.T) { t.Fatalf("AddPlayer() stored the raw password with whitespace for %q", tc.password) } - if ok, err := NewAuthentication(queries).CheckPlayerPassword(ctx, tc.username, strings.TrimSpace(tc.password)); err != nil || !ok { + if ok, err := NewAuthentication(queries, nil).CheckPlayerPassword(ctx, tc.username, strings.TrimSpace(tc.password)); err != nil || !ok { t.Fatalf("CheckPlayerPassword() should accept username=%q with trimmed password=%q: ok=%v err=%v", tc.username, strings.TrimSpace(tc.password), ok, err) } } @@ -166,7 +166,7 @@ func TestPlayerManagement_TrimPasswordWhitespaceBeforeStorage(t *testing.T) { func TestPlayerManagement_RejectsNilDependencies(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - auth := NewAuthentication(queries) + auth := NewAuthentication(queries, nil) if _, err := (&PlayerManagement{q: queries}).AddPlayer(ctx, "Alice", "alice", "secret123"); err == nil { t.Fatal("AddPlayer() accepted nil auth dependency") @@ -185,7 +185,7 @@ func TestPlayerManagement_RejectsNilDependencies(t *testing.T) { func TestPlayerManagement_RejectsInvalidIDs(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - auth := NewAuthentication(queries) + auth := NewAuthentication(queries, nil) uc := NewPlayerManagement(queries, auth) for _, id := range []int64{0, -1} { @@ -210,7 +210,7 @@ func TestPlayerManagement_RejectsInvalidIDs(t *testing.T) { func TestPlayerManagement_ForcePasswordUpdateBypassesPreviousPasswordCheck(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, err := uc.AddPlayer(ctx, "Carol", "carol", "secret123") if err != nil { @@ -233,7 +233,7 @@ func TestPlayerManagement_ForcePasswordUpdateBypassesPreviousPasswordCheck(t *te func TestPlayerManagement_RejectsWrongPreviousPassword(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, err := uc.AddPlayer(ctx, "Dana", "dana", "secret123") if err != nil { @@ -248,7 +248,7 @@ func TestPlayerManagement_RejectsWrongPreviousPassword(t *testing.T) { func TestPlayerManagement_DeletePlayer(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, err := uc.AddPlayer(ctx, "Bob", "bob", "secret123") if err != nil { @@ -274,7 +274,7 @@ func TestNewPlayerManagement_RejectsNilAuthDependency(t *testing.T) { func TestPlayerManagement_AddPlayerWithCancelledContext(t *testing.T) { queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) // Create a cancelled context ctx, cancel := context.WithCancel(context.Background()) @@ -289,7 +289,7 @@ func TestPlayerManagement_AddPlayerWithCancelledContext(t *testing.T) { func TestPlayerManagement_DuplicateUsername(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) // Add first player if _, err := uc.AddPlayer(ctx, "Alice", "alice", "secret123"); err != nil { @@ -305,8 +305,8 @@ func TestPlayerManagement_DuplicateUsername(t *testing.T) { func TestPlayerManagement_SuccessfulPlayerCRUDWithPasswordChange(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) - auth := NewAuthentication(queries) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) + auth := NewAuthentication(queries, nil) // Create player, err := uc.AddPlayer(ctx, "TestUser", "testuser", "password123") @@ -358,7 +358,7 @@ func TestPlayerManagement_SuccessfulPlayerCRUDWithPasswordChange(t *testing.T) { func TestPlayerManagement_UpdatePlayerNameWithCancelledContext(t *testing.T) { queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, _ := uc.AddPlayer(context.Background(), "Alice", "alice", "secret123") @@ -372,7 +372,7 @@ func TestPlayerManagement_UpdatePlayerNameWithCancelledContext(t *testing.T) { func TestPlayerManagement_UpdatePlayerPasswordWithCancelledContext(t *testing.T) { queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, _ := uc.AddPlayer(context.Background(), "Bob", "bob", "secret123") @@ -386,7 +386,7 @@ func TestPlayerManagement_UpdatePlayerPasswordWithCancelledContext(t *testing.T) func TestPlayerManagement_BaseUpdatePlayerPasswordWithCancelledContext(t *testing.T) { queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, _ := uc.AddPlayer(context.Background(), "Carol", "carol", "secret123") @@ -400,7 +400,7 @@ func TestPlayerManagement_BaseUpdatePlayerPasswordWithCancelledContext(t *testin func TestPlayerManagement_UpdatePlayerPasswordForceWithCancelledContext(t *testing.T) { queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, _ := uc.AddPlayer(context.Background(), "Dana", "dana", "secret123") @@ -414,7 +414,7 @@ func TestPlayerManagement_UpdatePlayerPasswordForceWithCancelledContext(t *testi func TestPlayerManagement_DeletePlayerWithCancelledContext(t *testing.T) { queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) created, _ := uc.AddPlayer(context.Background(), "Eve", "eve", "secret123") @@ -429,7 +429,7 @@ func TestPlayerManagement_DeletePlayerWithCancelledContext(t *testing.T) { func TestPlayerManagement_RejectsInvalidUsernameInput(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) // Test AddPlayer with invalid usernames invalidUsernames := []string{ @@ -449,7 +449,7 @@ func TestPlayerManagement_RejectsInvalidUsernameInput(t *testing.T) { func TestPlayerManagement_PasswordBoundaryValidation(t *testing.T) { ctx := context.Background() queries := newTestDB(t) - uc := NewPlayerManagement(queries, NewAuthentication(queries)) + uc := NewPlayerManagement(queries, NewAuthentication(queries, nil)) // Test AddPlayer password validation at boundaries testCases := []struct { @@ -486,7 +486,7 @@ func TestPlayerManagement_RejectsNilAuthDependencyInAllMethods(t *testing.T) { t.Fatal("AddPlayer() accepted nil auth") } - player, _ := NewPlayerManagement(queries, NewAuthentication(queries)).AddPlayer(ctx, "Bob", "bob", "secret123") + player, _ := NewPlayerManagement(queries, NewAuthentication(queries, nil)).AddPlayer(ctx, "Bob", "bob", "secret123") if err := uc.BaseUpdatePlayerPassword(ctx, player.ID, "newpass123"); err == nil { t.Fatal("BaseUpdatePlayerPassword() accepted nil auth") @@ -503,7 +503,7 @@ func TestPlayerManagement_RejectsNilAuthDependencyInAllMethods(t *testing.T) { func TestPlayerManagement_RejectsNilQueryDependencyInAllMethods(t *testing.T) { ctx := context.Background() - auth := NewAuthentication(newTestDB(t)) + auth := NewAuthentication(newTestDB(t), nil) // Create a PlayerManagement with auth but without queries uc := &PlayerManagement{q: nil, auth: auth} diff --git a/internal/core/port/driven.go b/internal/core/port/driven.go index 83a07fd..782f9f3 100644 --- a/internal/core/port/driven.go +++ b/internal/core/port/driven.go @@ -1,7 +1,14 @@ package port -import "time" +import ( + "keep-it-up/internal/infrastructure/database" + "time" +) type TimeProvider interface { Time() (time.Time, error) } + +type TokenGenerator interface { + GenerateToken(database.Player) (string, error) +} diff --git a/internal/infrastructure/constant/constant.go b/internal/infrastructure/constant/constant.go index 1e46aa6..877155d 100644 --- a/internal/infrastructure/constant/constant.go +++ b/internal/infrastructure/constant/constant.go @@ -3,3 +3,5 @@ package constant import "time" const DBDatetimeFormat string = time.RFC3339 + +const EnvFilename string = ".env" diff --git a/internal/infrastructure/driven/time_provider.go b/internal/infrastructure/driven/default_time_provider.go similarity index 100% rename from internal/infrastructure/driven/time_provider.go rename to internal/infrastructure/driven/default_time_provider.go diff --git a/internal/infrastructure/driven/jwt_token_generator.go b/internal/infrastructure/driven/jwt_token_generator.go new file mode 100644 index 0000000..739f6cc --- /dev/null +++ b/internal/infrastructure/driven/jwt_token_generator.go @@ -0,0 +1,46 @@ +package driven + +import ( + "errors" + "fmt" + "keep-it-up/internal/application/model" + "keep-it-up/internal/core/port" + "keep-it-up/internal/infrastructure/database" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type JwtTokenGenerator struct { + JwtSecret string + TimeProvider port.TimeProvider +} + +func (g *JwtTokenGenerator) GenerateToken(player database.Player) (string, error) { + if strings.TrimSpace(g.JwtSecret) == "" { + return "", errors.New("JWT secret cannot be empty string") + } + if g.TimeProvider == nil { + return "", errors.New("time provider is not initialized") + } + + t, err := g.TimeProvider.Time() + if err != nil { + return "", fmt.Errorf("failed to get current time: %w", err) + } + + claims := &model.JwtPlayerClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate( + t.Add(time.Hour * 72), + ), + }, + UserID: player.ID, + Username: player.Username, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + return token.SignedString([]byte(g.JwtSecret)) +} diff --git a/internal/infrastructure/driver/httpadapter/http.go b/internal/infrastructure/driver/httpadapter/http.go new file mode 100644 index 0000000..5997e8e --- /dev/null +++ b/internal/infrastructure/driver/httpadapter/http.go @@ -0,0 +1,154 @@ +package httpadapter + +import ( + "context" + "fmt" + "keep-it-up/internal/application/usecase" + "keep-it-up/internal/core/port" + "log" + "net/http" + "time" + + echojwt "github.com/labstack/echo-jwt/v5" + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" +) + +const JWTTokenCookieName string = "access_token" + +type Deps struct { + Auth port.Authentication +} + +type HTTPAdapter struct { + addr string + jwtSecret string + tp port.TimeProvider + d Deps +} + +func New(addr string, jwtSecret string, tp port.TimeProvider, d Deps) *HTTPAdapter { + return &HTTPAdapter{ + addr: addr, + jwtSecret: jwtSecret, + tp: tp, + d: d, + } +} + +func (h *HTTPAdapter) Run(ctx context.Context) error { + e := echo.New() + + e.Use(middleware.RequestLogger()) + e.Use(middleware.Recover()) + + unprotectedApi := e.Group("/api") + + unprotectedApi.POST("/login", func(ctx *echo.Context) error { + username := ctx.FormValue("username") + password := ctx.FormValue("password") + + res, err := h.d.Auth.LoginPlayer( + ctx.Request().Context(), + username, password, + ) + if err != nil { + if err == usecase.ErrBadRequest { + return ctx.JSON( + http.StatusBadRequest, + map[string]string{ + "message": "Missing username or password", + }, + ) + } + if err == usecase.ErrUnauthorized { + return ctx.JSON( + http.StatusUnauthorized, + map[string]string{ + "message": "Incorrect username or password", + }, + ) + } + + log.Printf("login error: %v", err) + return ctx.JSON( + http.StatusInternalServerError, + map[string]string{ + "message": "Something went wrong!", + }, + ) + } + + if h.tp == nil { + log.Printf("time provider is not initialized") + return ctx.JSON( + http.StatusInternalServerError, + map[string]string{ + "message": "Something went wrong!", + }, + ) + } + + t, err := h.tp.Time() + if err != nil { + log.Printf("failed to get current time: %v", err) + return ctx.JSON( + http.StatusInternalServerError, + map[string]string{ + "message": "Something went wrong!", + }, + ) + } + + ctx.SetCookie(&http.Cookie{ + Name: JWTTokenCookieName, + Value: res.Token, + Expires: t.Add(24 * time.Hour), + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + return ctx.JSON( + http.StatusNoContent, + map[string]string{ + "message": "Login successful; authentication cookies are set.", + }, + ) + }) + + api := unprotectedApi.Group("") + api.Use(echojwt.WithConfig(echojwt.Config{ + SigningKey: []byte(h.jwtSecret), + TokenLookup: fmt.Sprintf("cookie:%s", JWTTokenCookieName), + })) + + api.GET("/test", func(ctx *echo.Context) error { + return ctx.String(http.StatusOK, "Hello") + }) + + srv := &http.Server{ + Addr: h.addr, + Handler: e, + } + + errCh := make(chan error, 1) + go func() { + errCh <- srv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + log.Printf("Server stopping at %v...", h.addr) + if err := srv.Shutdown(shutdownCtx); err != nil { + return err + } + return <-errCh + case err := <-errCh: + return err + } +}