diff --git a/README.md b/README.md index 79132b4..4b0ed15 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,26 @@ The documentation can be founded at the path `/docs/swagger.yaml` or accessing t - `internal`: Directory to contain application code that should not be exposed to external packages. - `core`: Directory that contains the application's core business logic. - `thumb`: Directory contains definition of the entity's heights, interfaces, repository and service of the entity Thumb. + - `users`: Directory contains definition of the entity's heights, interfaces, repository and service of the entity User. - `adapters`: Directory to contain external services that will interact with the application core. - `db`: Directory contains the implementation of the repositories. - `rest`: Directory that contains the definition of the application's controllers and handlers for manipulating data provided by the controller - `domainerrors`: Directory that contains the definition of the application's domain errors. + +## Basic Authentication + +This project uses basic authentication to protect the endpoints. + +Example: + +| User | Password | +|-------|----------| +| test | test | + +```curl +curl 'http://localhost:8080/login' -H 'Authorization: Basic dGVzdDp0ZXN0' +``` + +## Creating new users + +Use the Swagger for access the endpoint `/user` and create a new user. \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 7885e8f..4301abc 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -14,7 +14,95 @@ const docTemplate = `{ }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", - "paths": {} + "paths": { + "/login": { + "get": { + "description": "Authenticates a user using Basic Authentication and returns user information.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "User Login", + "parameters": [ + { + "type": "string", + "description": "Basic Authentication credentials (username:password)", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "Successful login", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + } + } + } + }, + "/user": { + "post": { + "description": "Creates a new user with the provided nickname and password.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Create a new user", + "parameters": [ + { + "description": "User creation request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.CreateUserRequest" + } + } + ], + "responses": { + "201": { + "description": "User created", + "schema": {} + }, + "400": { + "description": "Bad request", + "schema": {} + } + } + } + } + }, + "definitions": { + "handler.CreateUserRequest": { + "type": "object", + "required": [ + "nickname", + "password" + ], + "properties": { + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + } + } + } + } }` // SwaggerInfo holds exported Swagger Info so clients can modify it diff --git a/docs/swagger.json b/docs/swagger.json index 5a3bc8e..bc416ac 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -8,5 +8,93 @@ }, "host": "localhost:8080", "basePath": "/", - "paths": {} + "paths": { + "/login": { + "get": { + "description": "Authenticates a user using Basic Authentication and returns user information.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "User Login", + "parameters": [ + { + "type": "string", + "description": "Basic Authentication credentials (username:password)", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "Successful login", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + } + } + } + }, + "/user": { + "post": { + "description": "Creates a new user with the provided nickname and password.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Create a new user", + "parameters": [ + { + "description": "User creation request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.CreateUserRequest" + } + } + ], + "responses": { + "201": { + "description": "User created", + "schema": {} + }, + "400": { + "description": "Bad request", + "schema": {} + } + } + } + } + }, + "definitions": { + "handler.CreateUserRequest": { + "type": "object", + "required": [ + "nickname", + "password" + ], + "properties": { + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + } + } + } + } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4d74dfb..2a834e4 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,9 +1,68 @@ basePath: / +definitions: + handler.CreateUserRequest: + properties: + nickname: + type: string + password: + type: string + required: + - nickname + - password + type: object host: localhost:8080 info: contact: {} description: Hackathon title: Thumb processor worker version: 0.1.0 -paths: {} +paths: + /login: + get: + consumes: + - application/json + description: Authenticates a user using Basic Authentication and returns user + information. + parameters: + - description: Basic Authentication credentials (username:password) + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: Successful login + schema: {} + "401": + description: Unauthorized + schema: {} + summary: User Login + tags: + - Auth + /user: + post: + consumes: + - application/json + description: Creates a new user with the provided nickname and password. + parameters: + - description: User creation request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/handler.CreateUserRequest' + produces: + - application/json + responses: + "201": + description: User created + schema: {} + "400": + description: Bad request + schema: {} + summary: Create a new user + tags: + - users swagger: "2.0" diff --git a/internal/adapters/db/user.go b/internal/adapters/db/user.go new file mode 100644 index 0000000..f91540a --- /dev/null +++ b/internal/adapters/db/user.go @@ -0,0 +1,6 @@ +package db + +type User struct { + Nickname string + Password string +} diff --git a/internal/adapters/rest/handler/login.go b/internal/adapters/rest/handler/login.go new file mode 100644 index 0000000..7828c5f --- /dev/null +++ b/internal/adapters/rest/handler/login.go @@ -0,0 +1,33 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "net/http" +) + +// RegisterLoginHandlers registers the login handler with the given Gin router group. +// +// @Summary User Login +// @Description Authenticates a user using Basic Authentication and returns user information. +// @Tags Auth +// @Accept json +// @Produce json +// @Param Authorization header string true "Basic Authentication credentials (username:password)" +// @Success 200 {object} interface{} "Successful login" +// @Failure 401 {object} interface{} "Unauthorized" +// @Router /login [get] +func RegisterLoginHandlers(authorizedGroup *gin.RouterGroup) { + authorizedGroup.GET("login", func(c *gin.Context) { + user, _, ok := c.Request.BasicAuth() + + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "nickname": user, + "status": "logged", + }) + }) +} diff --git a/internal/adapters/rest/handler/login_test.go b/internal/adapters/rest/handler/login_test.go new file mode 100644 index 0000000..9fa8a07 --- /dev/null +++ b/internal/adapters/rest/handler/login_test.go @@ -0,0 +1,68 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestRegisterLoginHandlers(t *testing.T) { + gin.SetMode(gin.TestMode) // Set Gin to test mode + + t.Run("Unauthorized - No Basic Auth", func(t *testing.T) { + router := gin.New() + authorizedGroup := router.Group("/") + RegisterLoginHandlers(authorizedGroup) + + req, _ := http.NewRequest("GET", "/login", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Unauthorized", response["error"]) + + }) + + t.Run("Authorized - Valid Basic Auth", func(t *testing.T) { + router := gin.New() + authorizedGroup := router.Group("/") + RegisterLoginHandlers(authorizedGroup) + + req, _ := http.NewRequest("GET", "/login", nil) + req.SetBasicAuth("testuser", "testpassword") // Set valid Basic Auth + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "testuser", response["nickname"]) + assert.Equal(t, "logged", response["status"]) + }) + + t.Run("Authorized - Empty Password", func(t *testing.T) { + router := gin.New() + authorizedGroup := router.Group("/") + RegisterLoginHandlers(authorizedGroup) + + req, _ := http.NewRequest("GET", "/login", nil) + req.SetBasicAuth("testuser", "") // Set valid Basic Auth, empty password + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "testuser", response["nickname"]) + assert.Equal(t, "logged", response["status"]) + }) +} diff --git a/internal/adapters/rest/handler/user.go b/internal/adapters/rest/handler/user.go new file mode 100644 index 0000000..d011683 --- /dev/null +++ b/internal/adapters/rest/handler/user.go @@ -0,0 +1,39 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/pangolin-do-golang/thumb-processor-api/internal/core/users" +) + +type CreateUserRequest struct { + Nickname string `json:"nickname" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// RegisterUserRoutes registers the user-related routes with the Gin engine. +// @Summary Create a new user +// @Description Creates a new user with the provided nickname and password. +// @Tags users +// @Accept json +// @Produce json +// @Param request body CreateUserRequest true "User creation request body" +// @Success 201 {object} interface{} "User created" +// @Failure 400 {object} interface{} "Bad request" +// @Router /user [post] +func RegisterUserRoutes(router *gin.Engine) { + router.POST("/user", createUserHandler) +} + +func createUserHandler(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + users.CreateUser(req.Nickname, req.Password) + + c.JSON(http.StatusCreated, gin.H{"message": "User created successfully"}) +} diff --git a/internal/adapters/rest/handler/user_test.go b/internal/adapters/rest/handler/user_test.go new file mode 100644 index 0000000..12f1e8e --- /dev/null +++ b/internal/adapters/rest/handler/user_test.go @@ -0,0 +1,80 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestCreateUserHandler(t *testing.T) { + // Gin setup + gin.SetMode(gin.TestMode) + router := gin.New() + RegisterUserRoutes(router) + + // Test cases + tests := []struct { + name string + requestBody CreateUserRequest + expectedStatus int + expectedResponse map[string]string + }{ + { + name: "Success", + requestBody: CreateUserRequest{ + Nickname: "testuser", + Password: "password123", + }, + expectedStatus: http.StatusCreated, + expectedResponse: map[string]string{"message": "User created successfully"}, + }, + { + name: "Validation Error - Empty Nickname", + requestBody: CreateUserRequest{ + Password: "password123", + }, + expectedStatus: http.StatusBadRequest, + expectedResponse: map[string]string{"error": "Key: 'CreateUserRequest.Nickname' Error:Field validation for 'Nickname' failed on the 'required' tag"}, + }, + { + name: "Validation Error - Empty Password", + requestBody: CreateUserRequest{ + Nickname: "testuser", + }, + expectedStatus: http.StatusBadRequest, + expectedResponse: map[string]string{"error": "Key: 'CreateUserRequest.Password' Error:Field validation for 'Password' failed on the 'required' tag"}, + }, + { + name: "Validation Error - Both Empty", + requestBody: CreateUserRequest{}, + expectedStatus: http.StatusBadRequest, + expectedResponse: map[string]string{"error": "Key: 'CreateUserRequest.Nickname' Error:Field validation for 'Nickname' failed on the 'required' tag\nKey: 'CreateUserRequest.Password' Error:Field validation for 'Password' failed on the 'required' tag"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create the request body in JSON format + requestBodyJSON, _ := json.Marshal(tt.requestBody) + req, _ := http.NewRequest("POST", "/user", bytes.NewBuffer(requestBodyJSON)) + req.Header.Set("Content-Type", "application/json") + + // Simulate the request + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Check the status code + assert.Equal(t, tt.expectedStatus, w.Code) + + // Check the response body + var response map[string]string + json.Unmarshal(w.Body.Bytes(), &response) + assert.Equal(t, tt.expectedResponse, response) + }) + } +} diff --git a/internal/adapters/rest/middleware/authmiddleware.go b/internal/adapters/rest/middleware/authmiddleware.go new file mode 100644 index 0000000..cf74b26 --- /dev/null +++ b/internal/adapters/rest/middleware/authmiddleware.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "net/http" +) + +func AuthMiddleware(allowedUsersFunc func() gin.Accounts) gin.HandlerFunc { + return func(c *gin.Context) { + accounts := allowedUsersFunc() // Get the latest user list + + username, password, ok := c.Request.BasicAuth() + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) + return + } + + user, ok := accounts[username] + if !ok || user != password { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"}) + return + } + + c.Next() // Continue to the handler + } +} diff --git a/internal/adapters/rest/middleware/authmiddleware_test.go b/internal/adapters/rest/middleware/authmiddleware_test.go new file mode 100644 index 0000000..c0d0dd5 --- /dev/null +++ b/internal/adapters/rest/middleware/authmiddleware_test.go @@ -0,0 +1,111 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestAuthMiddleware(t *testing.T) { + // Test Cases: + testCases := []struct { + name string + username string + password string + allowedUsersFunc func() gin.Accounts + expectedStatus int + expectedMessage string + }{ + { + name: "Valid Credentials", + username: "testuser", + password: "testpassword", + allowedUsersFunc: func() gin.Accounts { return gin.Accounts{"testuser": "testpassword"} }, + expectedStatus: http.StatusOK, // Or 200 if you're checking for a successful continuation + expectedMessage: "", // No message expected on success + }, + { + name: "Invalid Username", + username: "wronguser", + password: "testpassword", + allowedUsersFunc: func() gin.Accounts { return gin.Accounts{"testuser": "testpassword"} }, + expectedStatus: http.StatusUnauthorized, + expectedMessage: "Unauthorized", + }, + { + name: "Invalid Password", + username: "testuser", + password: "wrongpassword", + allowedUsersFunc: func() gin.Accounts { return gin.Accounts{"testuser": "testpassword"} }, + expectedStatus: http.StatusUnauthorized, + expectedMessage: "Unauthorized", + }, + { + name: "No Credentials Provided", + username: "", + password: "", + allowedUsersFunc: func() gin.Accounts { return gin.Accounts{"testuser": "testpassword"} }, + expectedStatus: http.StatusUnauthorized, + expectedMessage: "Unauthorized", + }, + { + name: "Empty Accounts", + username: "testuser", + password: "testpassword", + allowedUsersFunc: func() gin.Accounts { return gin.Accounts{} }, // Empty accounts + expectedStatus: http.StatusUnauthorized, + expectedMessage: "Unauthorized", + }, + { + name: "Nil Accounts", + username: "testuser", + password: "testpassword", + allowedUsersFunc: func() gin.Accounts { return nil }, // nil accounts + expectedStatus: http.StatusUnauthorized, + expectedMessage: "Unauthorized", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Setup + r := gin.New() + r.Use(AuthMiddleware(tc.allowedUsersFunc)) + + // A dummy handler to simulate what happens after authentication + var handlerCalled bool + r.GET("/test", func(c *gin.Context) { + handlerCalled = true + c.Status(http.StatusOK) // Or whatever your success status is + }) + + req, _ := http.NewRequest("GET", "/test", nil) + if tc.username != "" { + req.SetBasicAuth(tc.username, tc.password) + } + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Assertions + assert.Equal(t, tc.expectedStatus, w.Code) + + if tc.expectedMessage != "" { + var response map[string]string + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, tc.expectedMessage, response["message"]) + } + + if tc.expectedStatus == http.StatusOK { // Check if the handler was called only on success + assert.True(t, handlerCalled, "Handler should be called on successful authentication") + } else { + assert.False(t, handlerCalled, "Handler should not be called on failed authentication") + } + }) + } +} diff --git a/internal/adapters/rest/server/server.go b/internal/adapters/rest/server/server.go index dcd97cf..dcc9c5d 100644 --- a/internal/adapters/rest/server/server.go +++ b/internal/adapters/rest/server/server.go @@ -1,6 +1,7 @@ package server import ( + "github.com/pangolin-do-golang/thumb-processor-api/internal/core/users" "net/http" "github.com/gin-gonic/gin" @@ -25,6 +26,14 @@ func (rs RestServer) Serve() { c.Status(http.StatusOK) }) handler.RegisterSwaggerHandlers(r) + + handler.RegisterUserRoutes(r) + + // Rotes that need authentication + authorizedGroup := r.Group("/", middleware.AuthMiddleware(users.GetAllowedUsers)) + + handler.RegisterLoginHandlers(authorizedGroup) + err := r.Run("0.0.0.0:8080") if err != nil { panic(err) diff --git a/internal/adapters/rest/server/server_test.go b/internal/adapters/rest/server/server_test.go index c763863..33420b0 100644 --- a/internal/adapters/rest/server/server_test.go +++ b/internal/adapters/rest/server/server_test.go @@ -1,14 +1,51 @@ -package server_test +package server import ( - "github.com/pangolin-do-golang/thumb-processor-api/internal/adapters/rest/server" + "net/http" + "net/http/httptest" "testing" + + "github.com/gin-gonic/gin" + "github.com/pangolin-do-golang/thumb-processor-api/internal/adapters/rest/handler" + "github.com/pangolin-do-golang/thumb-processor-api/internal/adapters/rest/middleware" + "github.com/pangolin-do-golang/thumb-processor-api/internal/core/users" + "github.com/stretchr/testify/assert" ) -func TestServeStartsServerSuccessfully(t *testing.T) { - rs := server.NewRestServer(&server.RestServerOptions{}) +func TestRestServer_Serve_HealthCheck(t *testing.T) { + r := gin.Default() + r.Use(middleware.CorsMiddleware()) + r.GET("/health", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req, _ := http.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRestServer_Serve_UserRoutes(t *testing.T) { + r := gin.Default() + r.Use(middleware.CorsMiddleware()) + handler.RegisterUserRoutes(r) + + req, _ := http.NewRequest("GET", "/users", nil) // Example route + w := httptest.NewRecorder() + r.ServeHTTP(w, req) +} + +func TestRestServer_Serve_LoginRoutes_Unauthenticated(t *testing.T) { + r := gin.Default() + r.Use(middleware.CorsMiddleware()) + + authorizedGroup := r.Group("/", middleware.AuthMiddleware(users.GetAllowedUsers)) // Important! + handler.RegisterLoginHandlers(authorizedGroup) + + req, _ := http.NewRequest("GET", "/login", nil) // Example login route + w := httptest.NewRecorder() + r.ServeHTTP(w, req) - go func() { - rs.Serve() - }() + assert.Equal(t, http.StatusUnauthorized, w.Code) // Expect unauthorized because no token } diff --git a/internal/core/users/users.go b/internal/core/users/users.go new file mode 100644 index 0000000..760f7d2 --- /dev/null +++ b/internal/core/users/users.go @@ -0,0 +1,24 @@ +package users + +import ( + "github.com/gin-gonic/gin" + "github.com/pangolin-do-golang/thumb-processor-api/internal/adapters/db" +) + +var users = []db.User{ + {Nickname: "user", Password: "user"}, + {Nickname: "test", Password: "test"}, + {Nickname: "prod", Password: "prod"}, +} + +func GetAllowedUsers() gin.Accounts { + accounts := make(gin.Accounts) + for _, user := range users { + accounts[user.Nickname] = user.Password + } + return accounts +} + +func CreateUser(nickname, password string) { + users = append(users, db.User{Nickname: nickname, Password: password}) +} diff --git a/internal/core/users/users_test.go b/internal/core/users/users_test.go new file mode 100644 index 0000000..509519f --- /dev/null +++ b/internal/core/users/users_test.go @@ -0,0 +1,54 @@ +package users + +import ( + "testing" + + "github.com/pangolin-do-golang/thumb-processor-api/internal/adapters/db" + "github.com/stretchr/testify/assert" // Use testify for assertions +) + +func TestGetAllowedUsers(t *testing.T) { + // Test case 1: Empty users slice + originalUsers := users // Store the original users slice + users = []db.User{} + accounts := GetAllowedUsers() + assert.Empty(t, accounts, "Accounts should be empty when users slice is empty") + + // Test case 2: Populated users slice + users = originalUsers // Restore the original users slice + accounts = GetAllowedUsers() + assert.Len(t, accounts, len(users), "Accounts should have the same length as users slice") + for _, user := range users { + assert.Contains(t, accounts, user.Nickname, "Account should contain all nicknames") + assert.Equal(t, user.Password, accounts[user.Nickname], "Password should match for each user") + } + + // Test case 3: Check specific user + assert.Equal(t, "user", users[0].Nickname) + assert.Equal(t, "user", GetAllowedUsers()["user"]) + +} + +func TestCreateUser(t *testing.T) { + // Test case 1: Add a new user + initialLength := len(users) + CreateUser("newuser", "newpassword") + assert.Len(t, users, initialLength+1, "Users slice should have one more element") + newUser := users[len(users)-1] + assert.Equal(t, "newuser", newUser.Nickname, "New user nickname should be correct") + assert.Equal(t, "newpassword", newUser.Password, "New user password should be correct") + + // Test case 2: Add duplicate user (check if it's handled correctly or if an error is acceptable) + CreateUser("newuser", "anotherpassword") //Same user name + assert.Len(t, users, initialLength+2, "Users slice should have one more element") // It's appending the same user name again + newUser2 := users[len(users)-1] + assert.Equal(t, "newuser", newUser2.Nickname, "Duplicate user nickname should be correct") + assert.Equal(t, "anotherpassword", newUser2.Password, "Duplicate user password should be correct") //Password is different + + // Restore original user list after the tests + users = []db.User{ + {Nickname: "user", Password: "user"}, + {Nickname: "test", Password: "test"}, + {Nickname: "prod", Password: "prod"}, + } +}