diff --git a/.github/workflows/go-tests.yml b/.github/workflows/go-tests.yml new file mode 100644 index 0000000..acb2f56 --- /dev/null +++ b/.github/workflows/go-tests.yml @@ -0,0 +1,52 @@ +name: Go Application Tests + +on: + pull_request: + paths: + - 'backend/aashub/**' + push: + branches: [main] + paths: + - 'backend/aashub/**' + +jobs: + unit-tests: + name: Run Unit Tests + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Get dependencies + working-directory: backend/aashub + run: go mod download + - name: Run unit tests + run: go test ./... -v -tags=unit + working-directory: backend/aashub + + integration-tests: + name: Run Integration Tests + needs: unit-tests + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Set up Docker Compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + - name: Start all services with Docker Compose + run: docker-compose -f ci/docker-compose.yml up -d + - name: Wait for services to be ready + run: | + until [ "$(docker inspect --format='{{.State.Health.Status}}' app)" == "healthy" ]; do + echo "Current health status: $(docker inspect --format='{{.State.Health.Status}}' app)" + sleep 5 + done + - name: Run integration tests + run: docker exec app /bin/sh -c "cd /workspace/backend/aashub && go test ./... -tags=integration" + - name: Shutdown services + run: docker-compose -f ci/docker-compose.yml down diff --git a/.vscode/launch.json b/.vscode/launch.json index ed45ae1..fc92942 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "go", "request": "launch", "mode": "debug", - "program": "${workspaceFolder}/backend/aashub/main.go", + "program": "${workspaceFolder}/backend/aashub/cmd/aashub/main.go", "env": {}, "args": [] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 8991145..2494c5c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,6 @@ { + "go.buildFlags": ["-tags=unit,integration"], + "go.testFlags": ["-tags=unit,integration"], "[json]": { "editor.insertSpaces": true, "editor.formatOnSave": true, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c58e46e..1e25908 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -90,6 +90,21 @@ }, "problemMatcher": [], "detail": "Fix code formatting using Prettier" + }, + { + "label": "Build Go Docs", + "type": "shell", + "command": "swag", + "args": ["init", "-g", "cmd/aashub/main.go", "--parseDependency", "--parseInternal", "-o", "docs"], + "options": { + "cwd": "${workspaceFolder}/backend/aashub" + }, + "group": { + "kind": "build", + "isDefault": false + }, + "problemMatcher": [], + "detail": "Build Go documentation using Swag" } ] } diff --git a/backend/aashub/api/handler/users.go b/backend/aashub/api/handler/users.go new file mode 100644 index 0000000..fa7ea80 --- /dev/null +++ b/backend/aashub/api/handler/users.go @@ -0,0 +1,157 @@ +package api + +import ( + b64 "encoding/base64" + "encoding/json" + "net/http" + "time" + + repositories "github.com/aas-hub-org/aashub/internal/database/repositories" + interfaces "github.com/aas-hub-org/aashub/internal/interfaces" +) + +type APIUser struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` +} + +type UserHandler struct { + Repo interfaces.UserRepositoryInterface +} + +type VerificationHandler struct { + VerificationRepository interfaces.VerificationRepositoryInterface +} + +// RegisterUser registers a new user in the system. +// @Summary Register a new user +// @Description Registers a new user with the provided username, email, and password. +// @Tags users +// @Accept json +// @Produce json +// @Param user body APIUser true "User to register" +// @Success 201 {string} string "Successfully registered the user" +// @Failure 400 {string} string "Invalid request parameters" +// @Failure 500 {string} string "Internal server error" +// @Router /users/register [post] +func (h *UserHandler) RegisterUser(w http.ResponseWriter, r *http.Request) { + var user APIUser + err := json.NewDecoder(r.Body).Decode(&user) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Check if any of the required fields are empty + if user.Username == "" || user.Email == "" || user.Password == "" { + http.Error(w, "Missing required field(s)", http.StatusBadRequest) + return + } + + err = h.Repo.RegisterUser(user.Username, user.Email, user.Password) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +// VerifyUser godoc +// @Summary Verify user +// @Description Verifies a user using base64 URL encoded email and verification code. +// @Tags verification +// @Accept json +// @Produce json +// @Param email query string true "Base64 URL Encoded Email" +// @Param code query string true "Base64 URL Encoded Verification Code" +// @Success 200 {string} string "User verified successfully" +// @Failure 400 {string} string "Invalid email or code" +// @Failure 500 {string} string "Verification failed" +// @Router /verify [get] +func (h *VerificationHandler) VerifyUser(w http.ResponseWriter, r *http.Request) { + // Extract query parameters + email_byte, mail_decode_err := b64.RawURLEncoding.DecodeString(r.URL.Query().Get("email")) + code_byte, code_decode_err := b64.RawURLEncoding.DecodeString(r.URL.Query().Get("code")) + + if mail_decode_err != nil || code_decode_err != nil { + http.Error(w, "Invalid email or code", http.StatusBadRequest) + return + } + + email := string(email_byte) + code := string(code_byte) + + error_type, err := h.VerificationRepository.Verify(email, code) + if err != nil { + if error_type == "system" { + http.Error(w, "Verification failed", http.StatusInternalServerError) + return + } else { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + + // Write success response + w.WriteHeader(http.StatusOK) + w.Write([]byte("User verified successfully")) +} + +// LoginUser logs in a user, sets a cookie with a JWT token, and returns the token in the response +// @Summary User login and set cookie +// @Description Logs in a user by identifier (username or email) and password, sets a cookie with a JWT token if successful, and returns the JWT token in the response. +// @Tags users +// @Accept multipart/form-data +// @Produce json +// @Param identifier formData string true "Username or Email" +// @Param password formData string true "Password" +// @Success 204 "Successfully logged in" +// @Failure 400 {object} map[string]string "Missing required field(s) or bad request" +// @Failure 404 {object} map[string]string "User not found" +// @Failure 500 {object} map[string]string "Internal Server Error" +// @Router /users/login [post] +func (h *UserHandler) LoginUser(w http.ResponseWriter, r *http.Request) { + // Parse form data + err := r.ParseMultipartForm(32 << 20) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + identifier := r.FormValue("identifier") + password := r.FormValue("password") + + // Check if any of the required fields are empty + if identifier == "" || password == "" { + http.Error(w, "Missing required field(s)", http.StatusBadRequest) + return + } + + token, err := h.Repo.LoginUser(identifier, password) + if err != nil { + if err == repositories.ErrUserRepoNotFound { + http.Error(w, "User not found", http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Create a cookie + expiration := time.Now().Add(24 * time.Hour) // Set expiration to 24 hours from now + cookie := http.Cookie{ + Name: "token", // Name of the cookie + Value: token, // Token value + Expires: expiration, // Expiration time + HttpOnly: true, // Make the cookie HTTP-only (not accessible via JavaScript) + Path: "/", // Cookie path + // Secure: true, // Uncomment this if you are serving your site over HTTPS + } + + // Set the cookie in the response header + http.SetCookie(w, &cookie) + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/aashub/cmd/aashub/main.go b/backend/aashub/cmd/aashub/main.go new file mode 100644 index 0000000..cb990a6 --- /dev/null +++ b/backend/aashub/cmd/aashub/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "log" + "net/http" + "time" + + api "github.com/aas-hub-org/aashub/api/handler" + "github.com/aas-hub-org/aashub/internal/database" + repositories "github.com/aas-hub-org/aashub/internal/database/repositories" + + docs "github.com/aas-hub-org/aashub/docs" + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + swaggerfiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +// @BasePath /api/v1 + +// Health godoc +// @Summary Health Check +// @Description Responds with OK if the service is up and running +// @Tags health +// @Produce plain +// @Success 200 {string} string "OK" +// @Router /health [get] +func Health(g *gin.Context) { + g.JSON(http.StatusOK, "healthy") +} + +func main() { + r := gin.Default() + + // Configure CORS + corsConfig := cors.Config{ + AllowAllOrigins: true, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + } + r.Use(cors.New(corsConfig)) + + // Initialize database + database, err := database.NewDB() + if err != nil { + log.Fatalf("Could not connect to the database: %v", err) + } + + // Initialize repositories + verificationRepo := &repositories.VerificationRepository{DB: database} + mailVerificationRepo := &repositories.EmailVerificationRepository{VerificationRepository: verificationRepo} + userRepo := &repositories.UserRepository{DB: database, VerificationRepository: mailVerificationRepo} + + // Initialize handlers + userHandler := &api.UserHandler{Repo: userRepo} + verificationHandler := &api.VerificationHandler{VerificationRepository: verificationRepo} + + docs.SwaggerInfo.BasePath = "/api/v1" + v1 := r.Group("/api/v1") + { + ug := v1.Group("/users") + { + ug.POST("/register", gin.WrapF(userHandler.RegisterUser)) + ug.POST("/login", gin.WrapF(userHandler.LoginUser)) + } + vg := v1.Group("/verify") + { + vg.GET("/", gin.WrapF(verificationHandler.VerifyUser)) + } + } + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) + r.GET("/health", Health) + r.Run(":9000") +} diff --git a/backend/aashub/docs/docs.go b/backend/aashub/docs/docs.go index 32c471b..0eaf266 100644 --- a/backend/aashub/docs/docs.go +++ b/backend/aashub/docs/docs.go @@ -15,9 +15,92 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/example/helloworld": { + "/health": { "get": { - "description": "do ping", + "description": "Responds with OK if the service is up and running", + "produces": [ + "text/plain" + ], + "tags": [ + "health" + ], + "summary": "Health Check", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, + "/users/login": { + "post": { + "description": "Logs in a user by identifier (username or email) and password, sets a cookie with a JWT token if successful, and returns the JWT token in the response.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "User login and set cookie", + "parameters": [ + { + "type": "string", + "description": "Username or Email", + "name": "identifier", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Password", + "name": "password", + "in": "formData", + "required": true + } + ], + "responses": { + "204": { + "description": "Successfully logged in" + }, + "400": { + "description": "Missing required field(s) or bad request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "User not found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/users/register": { + "post": { + "description": "Registers a new user with the provided username, email, and password.", "consumes": [ "application/json" ], @@ -25,12 +108,86 @@ const docTemplate = `{ "application/json" ], "tags": [ - "example" + "users" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "User to register", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_handler.APIUser" + } + } + ], + "responses": { + "201": { + "description": "Successfully registered the user", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request parameters", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/verify": { + "get": { + "description": "Verifies a user using base64 URL encoded email and verification code.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "verification" + ], + "summary": "Verify user", + "parameters": [ + { + "type": "string", + "description": "Base64 URL Encoded Email", + "name": "email", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Base64 URL Encoded Verification Code", + "name": "code", + "in": "query", + "required": true + } ], - "summary": "ping example", "responses": { "200": { - "description": "OK", + "description": "User verified successfully", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid email or code", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Verification failed", "schema": { "type": "string" } @@ -38,6 +195,22 @@ const docTemplate = `{ } } } + }, + "definitions": { + "api_handler.APIUser": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + } } }` diff --git a/backend/aashub/docs/swagger.json b/backend/aashub/docs/swagger.json index 1882e9d..d99124f 100644 --- a/backend/aashub/docs/swagger.json +++ b/backend/aashub/docs/swagger.json @@ -5,9 +5,92 @@ }, "basePath": "/api/v1", "paths": { - "/example/helloworld": { + "/health": { "get": { - "description": "do ping", + "description": "Responds with OK if the service is up and running", + "produces": [ + "text/plain" + ], + "tags": [ + "health" + ], + "summary": "Health Check", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, + "/users/login": { + "post": { + "description": "Logs in a user by identifier (username or email) and password, sets a cookie with a JWT token if successful, and returns the JWT token in the response.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "User login and set cookie", + "parameters": [ + { + "type": "string", + "description": "Username or Email", + "name": "identifier", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Password", + "name": "password", + "in": "formData", + "required": true + } + ], + "responses": { + "204": { + "description": "Successfully logged in" + }, + "400": { + "description": "Missing required field(s) or bad request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "User not found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/users/register": { + "post": { + "description": "Registers a new user with the provided username, email, and password.", "consumes": [ "application/json" ], @@ -15,12 +98,86 @@ "application/json" ], "tags": [ - "example" + "users" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "User to register", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api_handler.APIUser" + } + } + ], + "responses": { + "201": { + "description": "Successfully registered the user", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request parameters", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "string" + } + } + } + } + }, + "/verify": { + "get": { + "description": "Verifies a user using base64 URL encoded email and verification code.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "verification" + ], + "summary": "Verify user", + "parameters": [ + { + "type": "string", + "description": "Base64 URL Encoded Email", + "name": "email", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Base64 URL Encoded Verification Code", + "name": "code", + "in": "query", + "required": true + } ], - "summary": "ping example", "responses": { "200": { - "description": "OK", + "description": "User verified successfully", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid email or code", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Verification failed", "schema": { "type": "string" } @@ -28,5 +185,21 @@ } } } + }, + "definitions": { + "api_handler.APIUser": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + } } } \ No newline at end of file diff --git a/backend/aashub/docs/swagger.yaml b/backend/aashub/docs/swagger.yaml index 50d3ee6..3eef1c1 100644 --- a/backend/aashub/docs/swagger.yaml +++ b/backend/aashub/docs/swagger.yaml @@ -1,20 +1,137 @@ basePath: /api/v1 +definitions: + api_handler.APIUser: + properties: + email: + type: string + password: + type: string + username: + type: string + type: object info: contact: {} paths: - /example/helloworld: + /health: get: + description: Responds with OK if the service is up and running + produces: + - text/plain + responses: + "200": + description: OK + schema: + type: string + summary: Health Check + tags: + - health + /users/login: + post: consumes: + - multipart/form-data + description: Logs in a user by identifier (username or email) and password, + sets a cookie with a JWT token if successful, and returns the JWT token in + the response. + parameters: + - description: Username or Email + in: formData + name: identifier + required: true + type: string + - description: Password + in: formData + name: password + required: true + type: string + produces: - application/json - description: do ping + responses: + "204": + description: Successfully logged in + "400": + description: Missing required field(s) or bad request + schema: + additionalProperties: + type: string + type: object + "404": + description: User not found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: User login and set cookie + tags: + - users + /users/register: + post: + consumes: + - application/json + description: Registers a new user with the provided username, email, and password. + parameters: + - description: User to register + in: body + name: user + required: true + schema: + $ref: '#/definitions/api_handler.APIUser' + produces: + - application/json + responses: + "201": + description: Successfully registered the user + schema: + type: string + "400": + description: Invalid request parameters + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Register a new user + tags: + - users + /verify: + get: + consumes: + - application/json + description: Verifies a user using base64 URL encoded email and verification + code. + parameters: + - description: Base64 URL Encoded Email + in: query + name: email + required: true + type: string + - description: Base64 URL Encoded Verification Code + in: query + name: code + required: true + type: string produces: - application/json responses: "200": - description: OK + description: User verified successfully + schema: + type: string + "400": + description: Invalid email or code + schema: + type: string + "500": + description: Verification failed schema: type: string - summary: ping example + summary: Verify user tags: - - example + - verification swagger: "2.0" diff --git a/backend/aashub/go.mod b/backend/aashub/go.mod index 75519b0..4351cc5 100644 --- a/backend/aashub/go.mod +++ b/backend/aashub/go.mod @@ -5,17 +5,28 @@ go 1.22.5 require ( github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 + github.com/go-sql-driver/mysql v1.8.1 + github.com/google/uuid v1.6.0 + github.com/gorilla/mux v1.8.1 + github.com/stretchr/testify v1.9.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.3 ) +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) + require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/bytedance/sonic v1.12.1 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/gabriel-vasile/mimetype v1.4.5 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -26,6 +37,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.22.0 // indirect github.com/goccy/go-json v0.10.3 // indirect + github.com/joho/godotenv v1.5.1 github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect @@ -38,7 +50,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.9.0 // indirect - golang.org/x/crypto v0.26.0 // indirect + golang.org/x/crypto v0.26.0 golang.org/x/net v0.28.0 // indirect golang.org/x/sys v0.23.0 // indirect golang.org/x/text v0.17.0 // indirect diff --git a/backend/aashub/go.sum b/backend/aashub/go.sum index 8650bb0..ab15a4a 100644 --- a/backend/aashub/go.sum +++ b/backend/aashub/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24= @@ -12,6 +14,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= @@ -38,11 +42,19 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/backend/aashub/internal/auth/jwt.go b/backend/aashub/internal/auth/jwt.go new file mode 100644 index 0000000..08f24ab --- /dev/null +++ b/backend/aashub/internal/auth/jwt.go @@ -0,0 +1,48 @@ +package auth + +import ( + "time" + + "github.com/dgrijalva/jwt-go" +) + +// Define a struct to hold your payload. You can add more fields as needed. +type CustomClaims struct { + Payload string `json:"payload"` + jwt.StandardClaims +} + +// Function to generate a JWT token with a string payload +func GenerateJWT(payload string, secretKey string) (string, error) { + // Set expiration time for the token + expirationTime := time.Now().Add(24 * time.Hour) + + // Create the claims with the payload and standard claims + claims := CustomClaims{ + Payload: payload, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expirationTime.Unix(), + }, + } + + // Create a new token object, specifying signing method and the claims + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign the token with your secret key + tokenString, err := token.SignedString([]byte(secretKey)) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func IsTokenValid(token string, secretkey string) (bool, error) { + _, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + return []byte(secretkey), nil + }) + if err != nil { + return false, err + } + return true, nil +} diff --git a/backend/aashub/internal/database/database.go b/backend/aashub/internal/database/database.go new file mode 100644 index 0000000..4aff071 --- /dev/null +++ b/backend/aashub/internal/database/database.go @@ -0,0 +1,25 @@ +package database + +import ( + "database/sql" + + _ "github.com/go-sql-driver/mysql" +) + +func NewDB() (*sql.DB, error) { + // Construct the Data Source Name (DSN) + dsn := "user:password@tcp(mariadb)/aashub?parseTime=true" + + // Open a DB connection + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, err + } + + // Test the DB connection + if err := db.Ping(); err != nil { + return nil, err + } + + return db, nil +} diff --git a/backend/aashub/internal/database/repositories/mailverification.go b/backend/aashub/internal/database/repositories/mailverification.go new file mode 100644 index 0000000..af49d79 --- /dev/null +++ b/backend/aashub/internal/database/repositories/mailverification.go @@ -0,0 +1,40 @@ +package database + +import ( + b64 "encoding/base64" + "os" + + mail "github.com/aas-hub-org/aashub/internal/mail" +) + +type EmailVerificationRepository struct { + VerificationRepository *VerificationRepository +} + +func (e *EmailVerificationRepository) CreateVerification(email string) (string, error) { + verificationCode, err := e.VerificationRepository.CreateVerification(email) + if err != nil { + return "", err + } + + var server = os.Getenv("SERVER_ADDRESS") + + encodedMail := b64.RawURLEncoding.EncodeToString([]byte(email)) + encodedCode := b64.RawURLEncoding.EncodeToString([]byte(verificationCode)) + + link := server + "/verify?email=" + encodedMail + "&code=" + encodedCode + println(link) + sendMailError := mail.SendEmail(email, "Verification Code", "Click here to verify your email") + if sendMailError != nil { + return "", sendMailError + } + + return verificationCode, nil +} + +func (e *EmailVerificationRepository) Verify(email string, verificationCode string) (string, error) { + if errtype, err := e.VerificationRepository.Verify(email, verificationCode); err != nil { + return errtype, err + } + return "", nil +} diff --git a/backend/aashub/internal/database/repositories/users.go b/backend/aashub/internal/database/repositories/users.go new file mode 100644 index 0000000..f660fea --- /dev/null +++ b/backend/aashub/internal/database/repositories/users.go @@ -0,0 +1,88 @@ +package database + +import ( + "database/sql" + "errors" + "log" + + auth "github.com/aas-hub-org/aashub/internal/auth" + interfaces "github.com/aas-hub-org/aashub/internal/interfaces" + utils "github.com/aas-hub-org/aashub/internal/utils" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +var ErrUserRepoNotFound = errors.New("identifier or password wrong") + +type UserRepository struct { + DB *sql.DB + VerificationRepository interfaces.VerificationRepositoryInterface +} + +type User struct { + ID string + Username string + Email string + Password string +} + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func (repo *UserRepository) RegisterUser(username string, email string, password string) error { + userid := uuid.New().String() + hashedpassword, err := HashPassword(password) + if err != nil { + log.Fatalf("Error hashing password: %v", err) + return err + } + _, err = repo.DB.Exec("INSERT INTO Users (id, username, email, password_hash) VALUES (?, ?, ?, ?)", userid, username, email, hashedpassword) + + if err != nil { + log.Fatalf("Error inserting user: %v", err) + return err + } + + _, err = repo.VerificationRepository.CreateVerification(email) + + if err != nil { + log.Fatalf("Error inserting verification: %v", err) + return err + } + + return nil +} + +func (repo *UserRepository) LoginUser(identifier string, password string) (string, error) { + // Changed the error message to 'identifier' to generalize username/email + var user User + + // Adjust the SQL query to check both the username and email fields + err := repo.DB.QueryRow("SELECT * FROM Users WHERE username = ? OR email = ?", identifier, identifier).Scan(&user.ID, &user.Username, &user.Email, &user.Password) + if err != nil { + return "", ErrUserRepoNotFound + } + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + return "", ErrUserRepoNotFound + } + secret, fileReadError := utils.ReadFile("/workspace/backend/aashub/privatekey.txt") + + if fileReadError != nil { + log.Fatalf("Error reading file: %v", fileReadError) + return "", fileReadError + } + + jwt, err := auth.GenerateJWT(user.ID, secret) + if err != nil { + log.Fatalf("Error generating JWT: %v", err) + return "", err + } + + return jwt, nil +} diff --git a/backend/aashub/internal/database/repositories/verification.go b/backend/aashub/internal/database/repositories/verification.go new file mode 100644 index 0000000..e8b6e90 --- /dev/null +++ b/backend/aashub/internal/database/repositories/verification.go @@ -0,0 +1,69 @@ +package database + +import ( + "errors" + "log" + + "database/sql" + "math/rand" + "time" +) + +type VerificationRepository struct { + DB *sql.DB +} + +func GenerateVerificationCode(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + var result []byte + seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) + + for i := 0; i < length; i++ { + index := seededRand.Intn(len(charset)) + result = append(result, charset[index]) + } + + return string(result) +} + +func (v *VerificationRepository) CreateVerification(email string) (string, error) { + verificationCode := GenerateVerificationCode(6) + + _, err := v.DB.Exec(` + INSERT INTO Verifications (email, verification_code, verified) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + verification_code = VALUES(verification_code), + verified = VALUES(verified)`, + email, verificationCode, false) + if err != nil { + return "", err + } + + return verificationCode, nil +} + +func (v *VerificationRepository) Verify(email string, verificationCode string) (string, error) { + log.Printf("Verifying email %s with code %s", email, verificationCode) + + result, select_err := v.DB.Query("SELECT * FROM Verifications WHERE email = ? AND verification_code = ? AND verified = ?", email, verificationCode, false) + + if select_err != nil { + log.Fatalf(select_err.Error()) + return "system", select_err + } + + defer result.Close() + + if !result.Next() { + return "user", errors.New("invalid verification code") + } + + _, err := v.DB.Exec("UPDATE Verifications SET verified = ? WHERE email = ? AND verification_code = ?", true, email, verificationCode) + if err != nil { + log.Fatalf(err.Error()) + return "system", err + } + + return "", nil +} diff --git a/backend/aashub/internal/interfaces/users.go b/backend/aashub/internal/interfaces/users.go new file mode 100644 index 0000000..608cfcc --- /dev/null +++ b/backend/aashub/internal/interfaces/users.go @@ -0,0 +1,6 @@ +package interfaces + +type UserRepositoryInterface interface { + RegisterUser(username string, email string, password string) error + LoginUser(username string, password string) (string, error) +} diff --git a/backend/aashub/internal/interfaces/verification.go b/backend/aashub/internal/interfaces/verification.go new file mode 100644 index 0000000..5c8ab99 --- /dev/null +++ b/backend/aashub/internal/interfaces/verification.go @@ -0,0 +1,6 @@ +package interfaces + +type VerificationRepositoryInterface interface { + CreateVerification(email string) (string, error) + Verify(email string, verificationCode string) (string, error) +} diff --git a/backend/aashub/internal/mail/mail.go b/backend/aashub/internal/mail/mail.go new file mode 100644 index 0000000..6fbbeb6 --- /dev/null +++ b/backend/aashub/internal/mail/mail.go @@ -0,0 +1,94 @@ +package mail + +import ( + "crypto/tls" + "log" + "net/smtp" + "os" + + "github.com/joho/godotenv" +) + +func SendEmail(to, subject, body string) error { + log.Printf("Sending email to %s", to) + // Load .env + env_err := godotenv.Load("/workspace/backend/aashub/.env") + if env_err != nil { + log.Fatalf("Error loading .env file") + } + + // Get from .env + from := os.Getenv("MAIL_ADDRESS") + pass := os.Getenv("MAIL_PASSWORD") + + // SMTP server configuration. + smtpHost := os.Getenv("MAIL_SMTP") + smtpPort := os.Getenv("SMTP_PORT") + + // Message. + message := []byte("From: " + from + "\n" + + "To: " + to + "\n" + + "Subject: " + subject + "\n" + + "Content-Type: text/html; charset=\"UTF-8\"\n\n" + + body) + + // TLS configuration + tlsconfig := &tls.Config{ + InsecureSkipVerify: true, + ServerName: smtpHost, + } + + // Connect to the SMTP Server + conn, err := tls.Dial("tcp", smtpHost+":"+smtpPort, tlsconfig) + if err != nil { + log.Fatalf("Error connecting to SMTP server: %v", err) + return err + } + + client, err := smtp.NewClient(conn, smtpHost) + if err != nil { + log.Fatalf("Error creating SMTP client: %v", err) + return err + } + + // Authentication + auth := smtp.PlainAuth("", from, pass, smtpHost) + if err = client.Auth(auth); err != nil { + log.Fatalf("Error authenticating: %v", err) + return err + } + + // To && From + if err = client.Mail(from); err != nil { + log.Fatalf("Error setting sender: %v", err) + return err + } + if err = client.Rcpt(to); err != nil { + log.Fatalf("Error setting recipient: %v", err) + return err + } + + // Data + w, err := client.Data() + if err != nil { + log.Fatalf("Error getting SMTP data writer: %v", err) + return err + } + + _, err = w.Write(message) + if err != nil { + log.Fatalf("Error writing message: %v", err) + return err + } + + err = w.Close() + if err != nil { + log.Fatalf("Error closing SMTP data writer: %v", err) + return err + } + + client.Quit() + + log.Printf("Email Sent Successfully!") + return nil +} diff --git a/backend/aashub/internal/utils/file.go b/backend/aashub/internal/utils/file.go new file mode 100644 index 0000000..caaa67c --- /dev/null +++ b/backend/aashub/internal/utils/file.go @@ -0,0 +1,21 @@ +package util + +import ( + "io" + "os" +) + +func ReadFile(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + bytes, err := io.ReadAll(file) + if err != nil { + return "", err + } + + return string(bytes), nil +} diff --git a/backend/aashub/main.go b/backend/aashub/main.go deleted file mode 100644 index 94b3ecf..0000000 --- a/backend/aashub/main.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "net/http" - "time" - - docs "github.com/aas-hub-org/aashub/docs" - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" - swaggerfiles "github.com/swaggo/files" - ginSwagger "github.com/swaggo/gin-swagger" -) - -// @BasePath /api/v1 - -// PingExample godoc -// @Summary ping example -// @Schemes -// @Description do ping -// @Tags example -// @Accept json -// @Produce json -// @Success 200 {string} Helloworld -// @Router /example/helloworld [get] -func Helloworld(g *gin.Context) { - g.JSON(http.StatusOK, "helloworld") -} - -func main() { - r := gin.Default() - - // Configure CORS - corsConfig := cors.Config{ - AllowAllOrigins: true, - AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, - ExposeHeaders: []string{"Content-Length"}, - AllowCredentials: true, - MaxAge: 12 * time.Hour, - } - r.Use(cors.New(corsConfig)) - - docs.SwaggerInfo.BasePath = "/api/v1" - v1 := r.Group("/api/v1") - { - eg := v1.Group("/example") - { - eg.GET("/helloworld", Helloworld) - } - } - r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) - r.Run(":9000") -} diff --git a/backend/aashub/privatekey.txt b/backend/aashub/privatekey.txt new file mode 100644 index 0000000..399c3ca --- /dev/null +++ b/backend/aashub/privatekey.txt @@ -0,0 +1 @@ +mockKey \ No newline at end of file diff --git a/backend/aashub/tests/integration/user_test.go b/backend/aashub/tests/integration/user_test.go new file mode 100644 index 0000000..31c3816 --- /dev/null +++ b/backend/aashub/tests/integration/user_test.go @@ -0,0 +1,232 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "bytes" + "database/sql" + "encoding/base64" + "encoding/json" + "fmt" + + "io" + "log" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + api "github.com/aas-hub-org/aashub/api/handler" + db "github.com/aas-hub-org/aashub/internal/database" + repositories "github.com/aas-hub-org/aashub/internal/database/repositories" +) + +type testCase struct { + name string + user api.APIUser + expectedStatus int + expectedBody string +} + +func teardown(database *sql.DB) { + // SQL statement to delete the test user, using ? as the placeholder + query := "DELETE FROM Users WHERE username = ?" + + // Execute the query for the test username + if _, err := database.Exec(query, "testuser"); err != nil { + log.Fatalf("Failed to clean up test user: %v", err) + } +} + +func TestRegisterUser(t *testing.T) { + // Initialize the database connection + database, err := db.NewDB() + if err != nil { + log.Fatalf("Could not connect to the database: %v", err) + } + + // Ensure teardown is called no matter what happens in the test + defer teardown(database) + + // Instantiate the repository + verifyRepo := &repositories.VerificationRepository{DB: database} + userRepo := &repositories.UserRepository{DB: database, VerificationRepository: verifyRepo} + + // Instantiate the handler struct with the repository + userHandler := &api.UserHandler{Repo: userRepo} + verifyHandler := &api.VerificationHandler{VerificationRepository: verifyRepo} + + // Create a new ServeMux. + mux := http.NewServeMux() + + // Register handlers for different endpoints. + mux.HandleFunc("/register", userHandler.RegisterUser) + mux.HandleFunc("/verify", verifyHandler.VerifyUser) + + // Create a new HTTP test server + ts := httptest.NewServer(mux) + defer ts.Close() + + // Define test cases + tests := []struct { + name string + user api.APIUser + expectedStatus int + expectedBody string + }{ + { + name: "Successful Registration", + user: api.APIUser{Username: "testuser", Email: "test@example.com", Password: "password123"}, + expectedStatus: http.StatusCreated, + expectedBody: "", + }, + { + name: "Missing Fields", + user: api.APIUser{Username: "", Email: "test@example.com", Password: "password123"}, + expectedStatus: http.StatusBadRequest, + expectedBody: "Missing required field(s)", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Marshal the user object to JSON + body, err := json.Marshal(tc.user) + if err != nil { + t.Fatalf("Failed to marshal user: %v", err) + } + + // Create a new POST request + req, err := http.NewRequest("POST", ts.URL+"/register", bytes.NewBuffer(body)) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + // Perform the request + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to perform request: %v", err) + } + defer resp.Body.Close() + + // Read the response body + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + // Assert the status code + if resp.StatusCode != tc.expectedStatus { + t.Errorf("Expected status code %d, got %d", tc.expectedStatus, resp.StatusCode) + } + + // Assert the response body if expected + if tc.expectedBody != "" && !strings.Contains(string(responseBody), tc.expectedBody) { + t.Errorf("Expected response body to contain %q, got %q", tc.expectedBody, string(responseBody)) + } + + // Call the verify user function + verifyUser(t, tc, ts, database) + }) + } +} + +func verifyUser(t *testing.T, tc testCase, ts *httptest.Server, database *sql.DB) { + // Assuming successful registration, proceed to verification + if tc.expectedStatus == http.StatusCreated { + // get the verification code from the database + var verificationCode string + query := "SELECT verification_code FROM Verifications WHERE email = ?" + if err := database.QueryRow(query, tc.user.Email).Scan(&verificationCode); err != nil { + t.Fatalf("Failed to get verification code: %v", err) + } + + // Base64 URL encode the email and verification code + emailEncoded := base64.RawURLEncoding.EncodeToString([]byte(tc.user.Email)) + codeEncoded := base64.RawURLEncoding.EncodeToString([]byte(verificationCode)) + + // Construct the verification URL with query parameters + verifyURL := fmt.Sprintf("%s/verify?email=%s&code=%s", ts.URL, url.QueryEscape(emailEncoded), url.QueryEscape(codeEncoded)) + + log.Printf("Verification URL: %s", verifyURL) + + // Create a new GET request to the verification endpoint + verifyReq, err := http.NewRequest("GET", verifyURL, nil) + if err != nil { + t.Fatalf("Failed to create verification request: %v", err) + } + + // Perform the verification request + verifyResp, err := http.DefaultClient.Do(verifyReq) + if err != nil { + t.Fatalf("Failed to perform verification request: %v", err) + } + defer verifyResp.Body.Close() + + // Read the verification response body + verifyResponseBody, err := io.ReadAll(verifyResp.Body) + if err != nil { + t.Fatalf("Failed to read verification response body: %v", err) + } + + log.Printf("Verification response: %s", verifyResponseBody) + + // Assert the verification status code and response body + if verifyResp.StatusCode != http.StatusOK { + t.Errorf("Expected verification status code %d, got %d", http.StatusOK, verifyResp.StatusCode) + } + if !strings.Contains(string(verifyResponseBody), "User verified successfully") { + t.Errorf("Expected verification response body to contain %q, got %q", "User verified successfully", string(verifyResponseBody)) + } + } +} + +func TestLoginUser(t *testing.T) { + // Initialize the database connection + database, err := db.NewDB() + if err != nil { + t.Fatalf("Could not connect to the database: %v", err) + } + + // Instantiate the repository + verifyRepo := &repositories.VerificationRepository{DB: database} + userRepo := &repositories.UserRepository{DB: database, VerificationRepository: verifyRepo} + + // Instantiate the handler struct with the repository + userHandler := &api.UserHandler{Repo: userRepo} + + // Create a buffer to write our multipart form data + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add the form fields + _ = writer.WriteField("identifier", "test") + _ = writer.WriteField("password", "test") + // Close the writer to finalize the multipart body + writer.Close() + + // Create a request to pass to our handler + req, err := http.NewRequest("POST", "/login", body) + if err != nil { + t.Fatal(err) + } + // Set the content type to multipart/form-data with the boundary parameter + req.Header.Set("Content-Type", writer.FormDataContentType()) + + // We create a ResponseRecorder to record the response + rr := httptest.NewRecorder() + handler := http.HandlerFunc(userHandler.LoginUser) + + // Our handlers satisfy http.Handler, so we can call their ServeHTTP method + // directly and pass in our Request and ResponseRecorder + handler.ServeHTTP(rr, req) + + // Check the status code is what we expect + if status := rr.Code; status != http.StatusNoContent { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusNoContent) + } +} diff --git a/backend/aashub/tests/unit/jwt_test.go b/backend/aashub/tests/unit/jwt_test.go new file mode 100644 index 0000000..aa74386 --- /dev/null +++ b/backend/aashub/tests/unit/jwt_test.go @@ -0,0 +1,73 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "strings" + "testing" + + "github.com/aas-hub-org/aashub/internal/auth" +) + +func TestGenerateJWTAndValidate(t *testing.T) { + // Define a payload and a secret key for testing + payload := "testPayload" + secretKey := "testSecretKey" + + // Generate a JWT token + tokenString, err := auth.GenerateJWT(payload, secretKey) + if err != nil { + t.Fatalf("Failed to generate JWT: %v", err) + } + + // Check if the token is valid + isValid, err := auth.IsTokenValid(tokenString, secretKey) + if err != nil { + t.Fatalf("Error validating token: %v", err) + } + + if !isValid { + t.Errorf("The token was expected to be valid") + } +} + +func TestGenerateJWTAndValidateWithManipulatedPayload(t *testing.T) { + expectedPayload := "testPayload" + manipulatedPayload := "manipulated" + secretKey := "testSecret" + + // Generate a JWT token + tokenString, err := auth.GenerateJWT(expectedPayload, secretKey) + if err != nil { + t.Fatalf("Failed to generate JWT: %v", err) + } + + // Generate a second JWT token + secondTokenString, err := auth.GenerateJWT(manipulatedPayload, secretKey) + if err != nil { + t.Fatalf("Failed to generate JWT: %v", err) + } + + // Check if the token is valid + isValid, err := auth.IsTokenValid(tokenString, secretKey) + if err != nil { + t.Fatalf("Error validating token: %v", err) + } + + if !isValid { + t.Errorf("The token was expected to be valid") + } + + // Split token at . + tokenParts := strings.Split(tokenString, ".") + secondTokenParts := strings.Split(secondTokenString, ".") + + newToken := tokenParts[0] + "." + secondTokenParts[1] + "." + tokenParts[2] + + // Check if the token is valid + _, err = auth.IsTokenValid(newToken, secretKey) + if err == nil { + t.Fatalf("Expected an error when validating token") + } +} diff --git a/backend/aashub/tests/unit/unit_test.go b/backend/aashub/tests/unit/unit_test.go new file mode 100644 index 0000000..964f532 --- /dev/null +++ b/backend/aashub/tests/unit/unit_test.go @@ -0,0 +1,6 @@ +//go:build unit +// +build unit + +package unit_test + +type MockRepository struct{} diff --git a/backend/aashub/tests/unit/user_test.go b/backend/aashub/tests/unit/user_test.go new file mode 100644 index 0000000..8938857 --- /dev/null +++ b/backend/aashub/tests/unit/user_test.go @@ -0,0 +1,181 @@ +//go:build unit +// +build unit + +package unit_test + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + b64 "encoding/base64" + + api "github.com/aas-hub-org/aashub/api/handler" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" +) + +func (m *MockRepository) RegisterUser(username, email, password string) error { + return nil +} + +func (repo *MockRepository) LoginUser(username string, password string) (string, error) { + return "", nil +} + +var ( + // VerifyFunc is a package-level variable that can be overridden in tests. + VerifyFunc func(email, code string) (string, error) +) + +func (m *MockRepository) CreateVerification(email string) (string, error) { + return "", nil +} + +func (m *MockRepository) Verify(email, code string) (string, error) { + if VerifyFunc != nil { + return VerifyFunc(email, code) + } + // Default behavior if VerifyFunc is not set + return "", nil +} + +func TestRegisterUser_Success(t *testing.T) { + mockRepo := &MockRepository{} + handler := api.UserHandler{Repo: mockRepo} + + user := api.APIUser{ + Username: "testUser", + Email: "test@example.com", + Password: "password123", + } + userJSON, err := json.Marshal(user) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest("POST", "/users/register", bytes.NewBuffer(userJSON)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + router := mux.NewRouter() + router.HandleFunc("/users/register", handler.RegisterUser) + router.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusCreated, rr.Code, "Expected status code 201") +} + +func TestRegisterUser_InvalidRequest(t *testing.T) { + mockRepo := &MockRepository{} + handler := api.UserHandler{Repo: mockRepo} + + user := api.APIUser{ + Username: "testUser", + Email: "test@example.com", + } + userJSON, err := json.Marshal(user) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest("POST", "/users/register", bytes.NewBuffer(userJSON)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + router := mux.NewRouter() + router.HandleFunc("/users/register", handler.RegisterUser) + router.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code, "Expected status code 400") +} + +func TestVerifyUser_Success(t *testing.T) { + // Set up the mock behavior + originalVerifyFunc := VerifyFunc + VerifyFunc = func(email, code string) (string, error) { + return "", nil // Simulate successful verification + } + defer func() { VerifyFunc = originalVerifyFunc }() // Reset VerifyFunc after the test + + mockRepo := &MockRepository{} + handler := api.VerificationHandler{VerificationRepository: mockRepo} + + // Simulate the request + email := "test@example.com" + code := "verificationCode" + emailEncoded := url.QueryEscape(b64.RawURLEncoding.EncodeToString([]byte(email))) + codeEncoded := url.QueryEscape(b64.RawURLEncoding.EncodeToString([]byte(code))) + + req, err := http.NewRequest("GET", "/verify?email="+emailEncoded+"&code="+codeEncoded, nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + router := mux.NewRouter() + router.HandleFunc("/verify", handler.VerifyUser) + router.ServeHTTP(rr, req) + + // Check the status code is what we expect. + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) + } + + // Check the response body is what we expect. + expected := "User verified successfully" + if rr.Body.String() != expected { + t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + } +} + +func TestVerifyUser_Failure(t *testing.T) { + // Set up the mock behavior for failure + originalVerifyFunc := VerifyFunc + VerifyFunc = func(email, code string) (string, error) { + return "user", errors.New("Invalid email or code") // Simulate verification failure due to invalid input + } + defer func() { VerifyFunc = originalVerifyFunc }() // Reset VerifyFunc after the test + + mockRepo := &MockRepository{} + handler := api.VerificationHandler{VerificationRepository: mockRepo} + + // Simulate the request with invalid email and code + email := "invalidEmail" + code := "invalidCode" + emailEncoded := url.QueryEscape(b64.RawURLEncoding.EncodeToString([]byte(email))) + codeEncoded := url.QueryEscape(b64.RawURLEncoding.EncodeToString([]byte(code))) + + req, err := http.NewRequest("GET", "/verify?email="+emailEncoded+"&code="+codeEncoded, nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + router := mux.NewRouter() + router.HandleFunc("/verify", handler.VerifyUser) + router.ServeHTTP(rr, req) + + // Check the status code is what we expect for failure. + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusBadRequest) + } + + // Check the response body is what we expect for failure. + actual := strings.TrimSpace(rr.Body.String()) + expected := strings.TrimSpace("Invalid email or code") + + if actual != expected { + t.Errorf("handler returned unexpected body: got %v want %v", actual, expected) + } +} diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml new file mode 100644 index 0000000..1772b17 --- /dev/null +++ b/ci/docker-compose.yml @@ -0,0 +1,38 @@ +services: + app: + image: mcr.microsoft.com/devcontainers/go:1-1.22-bookworm + container_name: app + volumes: + - ../:/workspace:cached + command: /bin/sh -c "cd /workspace/backend/aashub/cmd/aashub && go run main.go" + ports: + - 9000:9000 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9000/health > /proc/1/fd/1 2>/proc/1/fd/2 || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + depends_on: + mariadb: + condition: service_healthy + + mariadb: + image: mariadb:10.6 + container_name: mariadb + environment: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: aashub + MYSQL_USER: user + MYSQL_PASSWORD: password + volumes: + - ../mysql/init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + mysql-data: \ No newline at end of file