diff --git a/.env b/.env deleted file mode 100644 index 6d5f502b908a143939149a2e58f4a1823e7736c4..0000000000000000000000000000000000000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -DB_URL=jdbc:postgresql://localhost:5432/be-authentication -DB_USERNAME=postgres -DB_PASSWORD=postgres diff --git a/.github/workflows/production-cd.yml b/.github/workflows/production-cd.yml index 1e4a4112ab91d7e9ec6e17148606e5ec28ec732f..705daa42c453207eeba7d061a3a3b7905d05b3af 100644 --- a/.github/workflows/production-cd.yml +++ b/.github/workflows/production-cd.yml @@ -49,5 +49,5 @@ jobs: GOOGLE_PROJECT: ${{ secrets.GOOGLE_PROJECT }} run: | gcloud container clusters get-credentials safetypin-cluster --region asia-southeast2 - sed -i "s/GOOGLE_PROJECT/$GOOGLE_PROJECT/g" resources.yaml - kubectl apply -f resources.yaml \ No newline at end of file + sed -i "s/GOOGLE_PROJECT/$GOOGLE_PROJECT/g" production.yaml + kubectl apply -f production.yaml \ No newline at end of file diff --git a/.github/workflows/staging-ci-cd.yml b/.github/workflows/staging-ci-cd.yml index ccc2d24e7f4f9766978055dd6e464be9416947cc..b9f2245a1ecb24e75818e76ec49a7ccfddf507ff 100644 --- a/.github/workflows/staging-ci-cd.yml +++ b/.github/workflows/staging-ci-cd.yml @@ -40,16 +40,39 @@ jobs: uses: actions/checkout@v3 - name: Install the gcloud CLI - uses: google-github-actions/setup-gcloud@v0 + uses: google-github-actions/setup-gcloud@v2 with: project_id: ${{ secrets.GOOGLE_PROJECT }} service_account_key: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} export_default_credentials: true + - name: Authenticate with GCP + uses: google-github-actions/auth@v1 + with: + credentials_json: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} + - name: Build and Push Docker Image env: + PRODUCTION: staging GOOGLE_PROJECT: ${{ secrets.GOOGLE_PROJECT }} + JDBC_STAGING_DATABASE_PASSWORD: ${{ secrets.JDBC_STAGING_DATABASE_PASSWORD }} + JDBC_STAGING_DATABASE_URL: ${{ secrets.JDBC_STAGING_DATABASE_URL }} + JDBC_STAGING_DATABASE_USERNAME: ${{ secrets.JDBC_STAGING_DATABASE_USERNAME }} run: | gcloud auth configure-docker us-central1-docker.pkg.dev - docker build -t us-central1-docker.pkg.dev/$GOOGLE_PROJECT/my-repository/authentication:latest . - docker push us-central1-docker.pkg.dev/$GOOGLE_PROJECT/my-repository/authentication:latest \ No newline at end of file + docker build --build-arg PRODUCTION=$PRODUCTION --build-arg JDBC_STAGING_DATABASE_PASSWORD=$JDBC_STAGING_DATABASE_PASSWORD --build-arg JDBC_STAGING_DATABASE_URL=$JDBC_STAGING_DATABASE_URL --build-arg JDBC_STAGING_DATABASE_USERNAME=$JDBC_STAGING_DATABASE_USERNAME -t us-central1-docker.pkg.dev/$GOOGLE_PROJECT/staging-repository/authentication:latest . + docker push us-central1-docker.pkg.dev/$GOOGLE_PROJECT/staging-repository/authentication:latest + + - name: Install required components + run: | + gcloud components update + gcloud components install gke-gcloud-auth-plugin + + - name: Deploy to GKE + env: + GOOGLE_PROJECT: ${{ secrets.GOOGLE_PROJECT }} + GOOGLE_REPOSiTORY: staging-repository + run: | + gcloud container clusters get-credentials safetypin-staging --region asia-southeast2 + sed -i "s/GOOGLE_PROJECT/$GOOGLE_PROJECT/g" staging.yaml + kubectl apply -f staging.yaml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a5403eff656f15272fcb7e6c9c10de0edac79cd4..9372156b8f55652b70d6d0af1f7bde92551459ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,24 @@ RUN mvn clean package -DskipTests # Step 2: Use OpenJDK 21 to run the application FROM openjdk:21-jdk-slim + +# Setup envs +ARG PRODUCTION +ARG JDBC_DATABASE_PASSWORD +ARG JDBC_DATABASE_URL +ARG JDBC_DATABASE_USERNAME +ARG JDBC_STAGING_DATABASE_USERNAME +ARG JDBC_STAGING_DATABASE_URL +ARG JDBC_STAGING_DATABASE_URL + +ENV PRODUCTION ${PRODUCTION} +ENV JDBC_DATABASE_PASSWORD ${JDBC_DATABASE_PASSWORD} +ENV JDBC_DATABASE_URL ${JDBC_DATABASE_URL} +ENV JDBC_DATABASE_USERNAME ${JDBC_DATABASE_USERNAME} +ENV JDBC_STAGING_DATABASE_PASSWORD ${JDBC_STAGING_DATABASE_PASSWORD} +ENV JDBC_STAGING_DATABASE_URL ${JDBC_STAGING_DATABASE_URL} +ENV JDBC_STAGING_DATABASE_USERNAME ${JDBC_STAGING_DATABASE_USERNAME} + WORKDIR /app COPY --from=builder /app/target/authentication-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..10b7c73cebbb49f871efdd530649287715a73539 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# Authentication Microservice +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) + +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) + +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) +[](https://sonarcloud.io/summary/new_code?id=safetypin-official_be-authentication) + +## Overview + +The **Authentication Microservice** is a Spring Boot-based REST API that handles user authentication and authorization. It supports both traditional email-based registration/login as well as social authentication (e.g., Google, Apple). The service includes features such as OTP (One-Time Password) verification, password reset simulation, and a simple content posting endpoint for verified users. + +## Table of Contents + +- [Features](#features) +- [Tech Stack](#tech-stack) +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Configuration](#configuration) +- [API Endpoints](#api-endpoints) +- [Running Tests](#running-tests) +- [Development](#development) +- [Contributing](#contributing) +- [License](#license) +- [Acknowledgements](#acknowledgements) + +## Features + +- **Email Registration & Login:** Supports registration and login using email and password. +- **Social Authentication:** Simulated endpoints for registration and login using social providers. +- **OTP Verification:** Generates and verifies OTPs for user account validation. +- **Password Reset:** Simulated password reset functionality for email-based users. +- **Content Posting:** A secured endpoint that only allows verified users to post content. +- **Dev Data Seeder:** Automatically seeds development data when running under the `dev` profile. +- **Robust Testing:** Comprehensive unit and integration tests using JUnit 5, Spring Boot Test, and TestContainers. + +## Tech Stack + +- **Java:** 21 +- **Spring Boot:** 3.4.2 +- **Maven:** Build automation and dependency management +- **Spring Security:** For authentication and password encoding +- **Spring Data JPA:** For ORM and database access +- **PostgresSQL & H2:** Database support (PostgresSQL for production; H2 for development/testing) +- **JUnit 5 & TestContainers:** For testing and integration testing + +## Prerequisites + +- **Java 21** +- **Maven 3.6+** +- A running PostgresSQL instance (for production) or H2 (for development/testing) + +## Installation + +1. **Clone the repository:** +``` + git clone https://github.com/yourusername/authentication-microservice.git + cd authentication-microservice +``` +2. **Build the project using Maven:** +``` + mvn clean install +``` +3. **Run the application:** +``` + mvn spring-boot:run +``` + The service will start on the default port (typically 8080). + +## Configuration + +- **application.properties:** Configure your database, server port, and other environment-specific settings. +- **Profiles:** Use the `dev` profile for development. The `DevDataSeeder` will automatically seed sample user data when running under this profile: + +``` +mvn spring-boot:run -Dspring-boot.run.profiles=dev +``` + +## API Endpoints + +### Public Endpoints + +- **GET `/`** + Returns a simple "Hello, World!" greeting. + +### Authentication Endpoints + +- **POST `/api/auth/register-email`** + Registers a new user using email. + - **Request Body:** JSON containing `email`, `password`, `name`, and `birthdate`. + - **Response:** Success message with user data. + +- **POST `/api/auth/register-social`** + Registers or logs in a user using social authentication. + - **Request Body:** JSON containing `provider`, `socialToken`, `email`, `name`, `birthdate`, and `socialId`. + - **Response:** Success message with user data. + +- **POST `/api/auth/login-email`** + Authenticates a user using email and password. + - **Parameters:** `email`, `password`. + - **Response:** User data on successful login. + +- **POST `/api/auth/login-social`** + Logs in a user via social authentication. + - **Parameter:** `email`. + - **Response:** User data on successful social login. + +- **POST `/api/auth/verify-otp`** + Verifies the OTP for account validation. + - **Parameters:** `email`, `otp`. + - **Response:** Message indicating success or failure of OTP verification. + +- **POST `/api/auth/forgot-password`** + Simulates password reset for email-registered users. + - **Request Body:** JSON containing `email`. + - **Response:** Message indicating that reset instructions have been sent. + +- **POST `/api/auth/post`** + Allows posting of content for verified users. + - **Parameters:** `email`, `content`. + - **Response:** Success or failure message based on user verification. + +- **GET `/api/auth/dashboard`** + Returns dashboard data (currently a placeholder). + - **Response:** An empty JSON object. + +## Running Tests + +To run all unit and integration tests, execute: +``` +mvn test +``` + +Tests are written using JUnit 5 and cover controllers, services, repository interactions, and utility components such as OTP generation and validation. + +## Development + +- **Code Style:** The project adheres to standard Java coding conventions and uses Lombok to reduce boilerplate. +- **Continuous Integration:** Integration with CI tools is recommended. Test coverage is ensured using Maven Surefire and Failsafe plugins. +- **Debugging:** Utilize Spring Boot DevTools for hot-reloading during development. + +## Contributing + +Contributions are welcome! Please follow these steps: + +1. Fork the repository. +2. Create a new feature branch (git checkout -b feature/YourFeature). +3. Commit your changes (git commit -m 'Add some feature'). +4. Push to the branch (git push origin feature/YourFeature). +5. Open a Pull Request. + +Please ensure that your code adheres to the existing coding style and that all tests pass before submitting your PR. + +## License + +This project is licensed under the +Creative Commons Attribution-NoDerivatives 4.0 International (CC BY-ND 4.0) +License. See the LICENSE file for details. + +## Acknowledgements + +- Thanks to the Spring Boot team and the open-source community for their continuous contributions. +- Special thanks to contributors who have helped improve the project. + +## Author +SafetyPin Team +- Darrel Danadyaksa Poli - 2206081995 +- Fredo Melvern Tanzil - 2206024713 +- Sefriano Edsel Jieftara Djie - 2206818966= +- Alma Putri Nashrida - 2206814671 +- Andi Salsabila Ardian - 2206083571 +- Muhammad Raihan Akbar - 2206827674 + diff --git a/pom.xml b/pom.xml index 936334aefa29a5989e8e79243c956eaf4df98600..e9bfa77466c699d2a0b1304e3325e9c9a0ca593e 100644 --- a/pom.xml +++ b/pom.xml @@ -66,8 +66,8 @@ <!-- JUnit 5 for unit and regression testing --> <dependency> <groupId>org.junit.jupiter</groupId> - <artifactId>junit-jupiter-api</artifactId> - <version>5.7.1</version> + <artifactId>junit-jupiter</artifactId> + <version>5.11.2</version> <scope>test</scope> </dependency> @@ -79,11 +79,33 @@ <scope>test</scope> </dependency> + <dependency> + <groupId>org.postgresql</groupId> + <artifactId>postgresql</artifactId> + <scope>runtime</scope> + </dependency> + <!-- REST-assured for integration testing --> <dependency> <groupId>io.rest-assured</groupId> <artifactId>rest-assured</artifactId> - <version>4.4.0</version> + <version>4.5.1</version> + <scope>test</scope> + <exclusions> + <exclusion> + <groupId>org.codehaus.groovy</groupId> + <artifactId>groovy</artifactId> + </exclusion> + <exclusion> + <groupId>org.codehaus.groovy</groupId> + <artifactId>groovy-xml</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> + <groupId>io.rest-assured</groupId> + <artifactId>json-schema-validator</artifactId> + <version>4.5.1</version> <scope>test</scope> </dependency> @@ -94,6 +116,44 @@ <version>2.5.4</version> </dependency> + <!-- Spring Data JPA --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-data-jpa</artifactId> + </dependency> + + <!-- Spring Security for Password Encoding --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-security</artifactId> + </dependency> + + <!-- H2 Database for development/testing --> + <dependency> + <groupId>com.h2database</groupId> + <artifactId>h2</artifactId> + <scope>runtime</scope> + </dependency> + + <dependency> + <groupId>org.springframework.security</groupId> + <artifactId>spring-security-test</artifactId> + <version>6.1.1</version> + <scope>test</scope> + </dependency> + + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-validation</artifactId> + </dependency> + + <dependency> + <groupId>net.java.dev.jna</groupId> + <artifactId>jna-platform</artifactId> + <version>5.13.0</version> + <scope>test</scope> + </dependency> + </dependencies> <build> @@ -110,6 +170,7 @@ <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> + <version>1.18.36</version> </path> </annotationProcessorPaths> </configuration> @@ -141,6 +202,25 @@ <artifactId>maven-jar-plugin</artifactId> <version>3.2.0</version> </plugin> + <plugin> + <groupId>org.jacoco</groupId> + <artifactId>jacoco-maven-plugin</artifactId> + <version>0.8.12</version> + <executions> + <execution> + <goals> + <goal>prepare-agent</goal> + </goals> + </execution> + <execution> + <id>report</id> + <phase>test</phase> + <goals> + <goal>report</goal> + </goals> + </execution> + </executions> + </plugin> </plugins> </build> diff --git a/resources.yaml b/production.yaml similarity index 100% rename from resources.yaml rename to production.yaml diff --git a/src/main/java/com/safetypin/authentication/AuthenticationApplication.java b/src/main/java/com/safetypin/authentication/AuthenticationApplication.java index 9b1e9513e0085a1dad784230ee2924c42a16d1fd..b54a9d5381a33cdf0bc8f447f1dcd01b76f08e7f 100644 --- a/src/main/java/com/safetypin/authentication/AuthenticationApplication.java +++ b/src/main/java/com/safetypin/authentication/AuthenticationApplication.java @@ -8,7 +8,6 @@ import org.springframework.web.bind.annotation.RestController; @SpringBootApplication public class AuthenticationApplication { - public static void main(String[] args) { SpringApplication.run(AuthenticationApplication.class, args); } diff --git a/src/main/java/com/safetypin/authentication/controller/AuthenticationController.java b/src/main/java/com/safetypin/authentication/controller/AuthenticationController.java new file mode 100644 index 0000000000000000000000000000000000000000..ee3bcfc0e1aabb7a1fd410f60bebb12c5569ab3f --- /dev/null +++ b/src/main/java/com/safetypin/authentication/controller/AuthenticationController.java @@ -0,0 +1,110 @@ +package com.safetypin.authentication.controller; + +import com.safetypin.authentication.dto.AuthResponse; +import com.safetypin.authentication.dto.PasswordResetRequest; +import com.safetypin.authentication.dto.RegistrationRequest; +import com.safetypin.authentication.dto.SocialLoginRequest; +import com.safetypin.authentication.exception.InvalidCredentialsException; +import com.safetypin.authentication.exception.UserAlreadyExistsException; +import com.safetypin.authentication.model.User; +import com.safetypin.authentication.service.AuthenticationService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +public class AuthenticationController { + + private final AuthenticationService authenticationService; + + public AuthenticationController(AuthenticationService authenticationService) { + this.authenticationService = authenticationService; + } + + + // Endpoint for email registration + @PostMapping("/register-email") + public ResponseEntity<AuthResponse> registerEmail(@Valid @RequestBody RegistrationRequest request) { + User user; + try { + user = authenticationService.registerUser(request); + } catch (IllegalArgumentException | UserAlreadyExistsException e) { + AuthResponse response = new AuthResponse(false, e.getMessage(), null); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + return ResponseEntity.ok().body(new AuthResponse(true, "OK", user)); + } + + // Endpoint for social registration/login + @PostMapping("/register-social") + public ResponseEntity<AuthResponse> registerSocial(@Valid @RequestBody SocialLoginRequest request) { + User user; + try { + user = authenticationService.socialLogin(request); + } catch (IllegalArgumentException | UserAlreadyExistsException e) { + AuthResponse response = new AuthResponse(false, e.getMessage(), null); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + return ResponseEntity.ok().body(new AuthResponse(true, "OK", user)); + } + + // OTP verification endpoint + @PostMapping("/verify-otp") + public String verifyOTP(@RequestParam String email, @RequestParam String otp) { + boolean verified = authenticationService.verifyOTP(email, otp); + return verified ? "User verified successfully" : "OTP verification failed"; + } + + + + // Endpoint for email login + @PostMapping("/login-email") + public ResponseEntity<Object> loginEmail(@RequestParam String email, @RequestParam String password) { + try { + return ResponseEntity.ok(authenticationService.loginUser(email, password)); + } catch (InvalidCredentialsException e){ + AuthResponse response = new AuthResponse(false, e.getMessage(), null); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + } + + // Endpoint for social login + @PostMapping("/login-social") + public ResponseEntity<Object> loginSocial(@RequestParam String email) { + try { + return ResponseEntity.ok(authenticationService.loginSocial(email)); + } catch (InvalidCredentialsException e){ + AuthResponse response = new AuthResponse(false, e.getMessage(), null); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + } + + + + + // Endpoint for forgot password (only for email users) + @PostMapping("/forgot-password") + public String forgotPassword(@Valid @RequestBody PasswordResetRequest request) { + authenticationService.forgotPassword(request.getEmail()); + return "Password reset instructions have been sent to your email (simulated)"; + } + + + + + // Endpoint simulating a content post that requires a verified account + @PostMapping("/post") + public String postContent(@RequestParam String email, @RequestParam String content) { + return authenticationService.postContent(email, content); + } + + // On successful login, return an empty map as a placeholder for future reports + @GetMapping("/dashboard") + public String dashboard() { + return "{}"; + } +} diff --git a/src/main/java/com/safetypin/authentication/dto/AuthResponse.java b/src/main/java/com/safetypin/authentication/dto/AuthResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..a29962ff41457e80c4adbbb6f198b35b121751b4 --- /dev/null +++ b/src/main/java/com/safetypin/authentication/dto/AuthResponse.java @@ -0,0 +1,13 @@ +package com.safetypin.authentication.dto; + +import lombok.*; + +@Data +@Getter +@Setter +@AllArgsConstructor +public class AuthResponse { + private boolean success; + private String message; + private Object data; +} \ No newline at end of file diff --git a/src/main/java/com/safetypin/authentication/dto/ErrorResponse.java b/src/main/java/com/safetypin/authentication/dto/ErrorResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..e7bb4102d7b8ee0ef4a89fd83cbef9406ccb6922 --- /dev/null +++ b/src/main/java/com/safetypin/authentication/dto/ErrorResponse.java @@ -0,0 +1,25 @@ +package com.safetypin.authentication.dto; + +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Data +@Getter +@Setter +@NoArgsConstructor +public class ErrorResponse{ + private int status; + private String message; + private LocalDateTime timestamp; + + public ErrorResponse(int status, String message) { + this.status = status; + this.message = message; + this.timestamp = LocalDateTime.now(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/safetypin/authentication/dto/PasswordResetRequest.java b/src/main/java/com/safetypin/authentication/dto/PasswordResetRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..9b12ed833092cf9fb644dbaae68983653b21b21c --- /dev/null +++ b/src/main/java/com/safetypin/authentication/dto/PasswordResetRequest.java @@ -0,0 +1,18 @@ +package com.safetypin.authentication.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class PasswordResetRequest { + + @NotBlank + @Email + private String email; + + // Getters and setters + +} diff --git a/src/main/java/com/safetypin/authentication/dto/RegistrationRequest.java b/src/main/java/com/safetypin/authentication/dto/RegistrationRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..ddb267f1f3b933e64dd9fff109b3123a0b5d3d12 --- /dev/null +++ b/src/main/java/com/safetypin/authentication/dto/RegistrationRequest.java @@ -0,0 +1,30 @@ +package com.safetypin.authentication.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +@Setter +@Getter +public class RegistrationRequest { + + @NotBlank + @Email + private String email; + + @NotBlank + private String password; + + @NotBlank + private String name; + + @NotNull + private LocalDate birthdate; + + // Getters and setters + +} diff --git a/src/main/java/com/safetypin/authentication/dto/SocialLoginRequest.java b/src/main/java/com/safetypin/authentication/dto/SocialLoginRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..1b34b2e38e3975795f5bacbbd33cd55aafab53a7 --- /dev/null +++ b/src/main/java/com/safetypin/authentication/dto/SocialLoginRequest.java @@ -0,0 +1,35 @@ +package com.safetypin.authentication.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +@Setter +@Getter +public class SocialLoginRequest { + + @NotBlank + private String provider; // "GOOGLE" or "APPLE" + + @NotBlank + private String socialToken; // Token from the social provider + + // Simulated fields as if retrieved from the provider + @NotBlank + private String email; + + @NotBlank + private String name; + + @NotNull + private LocalDate birthdate; + + @NotBlank + private String socialId; // ID provided by the social provider + + // Getters and setters + +} diff --git a/src/main/java/com/safetypin/authentication/exception/InvalidCredentialsException.java b/src/main/java/com/safetypin/authentication/exception/InvalidCredentialsException.java new file mode 100644 index 0000000000000000000000000000000000000000..e43d14a97b4af00b304ef223461aab63f87d80eb --- /dev/null +++ b/src/main/java/com/safetypin/authentication/exception/InvalidCredentialsException.java @@ -0,0 +1,7 @@ +package com.safetypin.authentication.exception; + +public class InvalidCredentialsException extends RuntimeException { + public InvalidCredentialsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/safetypin/authentication/exception/UserAlreadyExistsException.java b/src/main/java/com/safetypin/authentication/exception/UserAlreadyExistsException.java new file mode 100644 index 0000000000000000000000000000000000000000..c31a5a7d67770f3d328acf0db7436058bc44dc54 --- /dev/null +++ b/src/main/java/com/safetypin/authentication/exception/UserAlreadyExistsException.java @@ -0,0 +1,7 @@ +package com.safetypin.authentication.exception; + +public class UserAlreadyExistsException extends RuntimeException { + public UserAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/safetypin/authentication/model/User.java b/src/main/java/com/safetypin/authentication/model/User.java new file mode 100644 index 0000000000000000000000000000000000000000..41e776c80b3d4fa70956243f206185478de6a340 --- /dev/null +++ b/src/main/java/com/safetypin/authentication/model/User.java @@ -0,0 +1,59 @@ +package com.safetypin.authentication.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +@Entity +@Table(name = "users") +@NoArgsConstructor +public class User { + + @Id + @Setter @Getter + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Setter @Getter + @Column(nullable = false, unique = true) + private String email; + + // May be null for social login users + @Setter @Getter + @Column(nullable = false) + private String password; + + @Setter @Getter + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private boolean isVerified = false; + + @Setter @Getter + private String role; + + // New fields + @Setter @Getter + private LocalDate birthdate; + + @Setter @Getter + private String provider; // "EMAIL", "GOOGLE", "APPLE" + + @Setter @Getter + private String socialId; // For social login users + + + // Getters and setters + + public boolean isVerified() { + return isVerified; + } + public void setVerified(boolean verified) { + isVerified = verified; + } + +} diff --git a/src/main/java/com/safetypin/authentication/repository/UserRepository.java b/src/main/java/com/safetypin/authentication/repository/UserRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..bef7fa961afab7cbb1e697d0578cb9037f619af1 --- /dev/null +++ b/src/main/java/com/safetypin/authentication/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.safetypin.authentication.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import com.safetypin.authentication.model.User; + +@Repository +public interface UserRepository extends JpaRepository<User, Long> { + User findByEmail(String email); +} diff --git a/src/main/java/com/safetypin/authentication/security/PasswordEncoderConfig.java b/src/main/java/com/safetypin/authentication/security/PasswordEncoderConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..a4e107fa1ac73de8fa0c5389dcca59178bcf043e --- /dev/null +++ b/src/main/java/com/safetypin/authentication/security/PasswordEncoderConfig.java @@ -0,0 +1,14 @@ +package com.safetypin.authentication.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/safetypin/authentication/security/SecurityConfig.java b/src/main/java/com/safetypin/authentication/security/SecurityConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..dae1a161a0afaada40ea379b67e9614dc16cfd35 --- /dev/null +++ b/src/main/java/com/safetypin/authentication/security/SecurityConfig.java @@ -0,0 +1,34 @@ +package com.safetypin.authentication.security; + + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // CSRF protection is enabled by default, so we don't disable it here + .authorizeHttpRequests(auth -> auth + .requestMatchers("/**").permitAll() // Allow all requests + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // No session + .formLogin(AbstractHttpConfigurer::disable) // Disable login page + .httpBasic(AbstractHttpConfigurer::disable); // Disable basic authentication + + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } +} diff --git a/src/main/java/com/safetypin/authentication/seeder/DevDataSeeder.java b/src/main/java/com/safetypin/authentication/seeder/DevDataSeeder.java new file mode 100644 index 0000000000000000000000000000000000000000..e8644025b263b5c66b039a5a80c4703d83e45a3f --- /dev/null +++ b/src/main/java/com/safetypin/authentication/seeder/DevDataSeeder.java @@ -0,0 +1,93 @@ +package com.safetypin.authentication.seeder; + +import com.safetypin.authentication.model.User; +import com.safetypin.authentication.repository.UserRepository; +import static com.safetypin.authentication.service.AuthenticationService.EMAIL_PROVIDER; + +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@Profile({"dev"}) +public class DevDataSeeder implements Runnable { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public DevDataSeeder(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @PostConstruct + public void init() { + run(); + } + + @Override + public void run() { + // Only seed if there are no users in the repository + if (userRepository.count() == 0) { + + User user1 = new User(); + user1.setEmail("user1@example.com"); + user1.setPassword(passwordEncoder.encode("password1")); //NOSONAR + user1.setName("User One"); + user1.setVerified(true); + user1.setRole("user"); + user1.setBirthdate(LocalDate.of(1990, 1, 1)); + user1.setProvider(EMAIL_PROVIDER); + user1.setSocialId("social1"); + userRepository.save(user1); + + User user2 = new User(); + user2.setEmail("user2@example.com"); + user2.setPassword(passwordEncoder.encode("password2")); //NOSONAR + user2.setName("User Two"); + user2.setVerified(true); + user2.setRole("user"); + user2.setBirthdate(LocalDate.of(1991, 2, 2)); + user2.setProvider(EMAIL_PROVIDER); + user2.setSocialId("social2"); + userRepository.save(user2); + + + User user3 = new User(); + user3.setEmail("user3@example.com"); + user3.setPassword(passwordEncoder.encode("password3")); //NOSONAR + user3.setName("User Three"); + user3.setVerified(true); + user3.setRole("user"); + user3.setBirthdate(LocalDate.of(1992, 3, 3)); + user3.setProvider(EMAIL_PROVIDER); + user3.setSocialId("social3"); + userRepository.save(user3); + + User user4 = new User(); + user4.setEmail("user4@example.com"); + user4.setPassword(passwordEncoder.encode("password4")); //NOSONAR + user4.setName("User Four"); + user4.setVerified(true); + user4.setRole("user"); + user4.setBirthdate(LocalDate.of(1993, 4, 4)); + user4.setProvider(EMAIL_PROVIDER); + user4.setSocialId("social4"); + userRepository.save(user4); + + User user5 = new User(); + user5.setEmail("user5@example.com"); + user5.setPassword(passwordEncoder.encode("password5")); //NOSONAR + user5.setName("User Five"); + user5.setVerified(true); + user5.setRole("user"); + user5.setBirthdate(LocalDate.of(1994, 5, 5)); + user5.setProvider(EMAIL_PROVIDER); + user5.setSocialId("social5"); + userRepository.save(user5); + } + } +} diff --git a/src/main/java/com/safetypin/authentication/service/AuthenticationService.java b/src/main/java/com/safetypin/authentication/service/AuthenticationService.java new file mode 100644 index 0000000000000000000000000000000000000000..71e36836ec78ac0c369da2522698072b937f0b51 --- /dev/null +++ b/src/main/java/com/safetypin/authentication/service/AuthenticationService.java @@ -0,0 +1,154 @@ +package com.safetypin.authentication.service; + +import com.safetypin.authentication.dto.RegistrationRequest; +import com.safetypin.authentication.dto.SocialLoginRequest; +import com.safetypin.authentication.exception.InvalidCredentialsException; +import com.safetypin.authentication.exception.UserAlreadyExistsException; +import com.safetypin.authentication.model.User; +import com.safetypin.authentication.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import java.time.LocalDate; +import java.time.Period; + +@Service +public class AuthenticationService { + public static final String EMAIL_PROVIDER = "EMAIL"; + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final OTPService otpService; + private static final Logger logger = LoggerFactory.getLogger(AuthenticationService.class); + + public AuthenticationService(UserRepository userRepository, PasswordEncoder passwordEncoder, OTPService otpService) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.otpService = otpService; + } + + // Registration using email – includes birthdate and OTP generation + public User registerUser(RegistrationRequest request) { + if (calculateAge(request.getBirthdate()) < 16) { + throw new IllegalArgumentException("User must be at least 16 years old"); + } + User existingUser = userRepository.findByEmail(request.getEmail()); + if (existingUser != null) { + throw new UserAlreadyExistsException("Email address is already registered. If you previously used social login (Google/Apple), please use that method to sign in."); + } + String encodedPassword = passwordEncoder.encode(request.getPassword()); + + User user = new User(); + user.setEmail(request.getEmail()); + user.setPassword(encodedPassword); + user.setName(request.getName()); + user.setVerified(false); + user.setRole("USER"); + user.setBirthdate(request.getBirthdate()); + user.setProvider(EMAIL_PROVIDER); + user.setSocialId(null); + user = userRepository.save(user); + otpService.generateOTP(request.getEmail()); + logger.info("OTP generated for user at {}", java.time.LocalDateTime.now()); + return user; + } + + // Social registration/login – simulating data fetched from Google/Apple + public User socialLogin(SocialLoginRequest request) { + if (calculateAge(request.getBirthdate()) < 16) { + throw new IllegalArgumentException("User must be at least 16 years old"); + } + User existing = userRepository.findByEmail(request.getEmail()); + if (existing != null) { + if (EMAIL_PROVIDER.equals(existing.getProvider())) { + throw new UserAlreadyExistsException("An account with this email exists. Please sign in using your email and password."); + } + return existing; + } + User user = new User(); + user.setEmail(request.getEmail()); + user.setPassword(null); + user.setName(request.getName()); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(request.getBirthdate()); + user.setProvider(request.getProvider().toUpperCase()); + user.setSocialId(request.getSocialId()); + + user = userRepository.save(user); + logger.info("User registered via social login at {}", java.time.LocalDateTime.now()); + return user; + } + + // Email login with detailed error messages + public User loginUser(String email, String rawPassword) { + User user = userRepository.findByEmail(email); + if (user == null) { + // email not exists + logger.warn("Login failed: Email not found"); + throw new InvalidCredentialsException("Invalid email"); + } + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + // incorrect password + logger.warn("Login failed: Incorrect password attempt"); + throw new InvalidCredentialsException("Invalid password"); + } + logger.info("User logged in at {}", java.time.LocalDateTime.now()); + return user; + } + + // Social login verification (assumed to be pre-verified externally) + public User loginSocial(String email) { + User user = userRepository.findByEmail(email); + if (user == null) { + throw new InvalidCredentialsException("Social login failed: Email not found"); + } + logger.info("User logged in via social authentication at {}", java.time.LocalDateTime.now()); + return user; + } + + // OTP verification – marks user as verified upon success + public boolean verifyOTP(String email, String otp) { + boolean result = otpService.verifyOTP(email, otp); + if (result) { + User user = userRepository.findByEmail(email); + if (user != null) { + user.setVerified(true); + userRepository.save(user); + logger.info("OTP successfully verified at {}", java.time.LocalDateTime.now()); + } + } else { + logger.warn("OTP verification failed at {}", java.time.LocalDateTime.now()); + } + return result; + } + + // Forgot password – only applicable for email-registered users + public void forgotPassword(String email) { + User user = userRepository.findByEmail(email); + if (user == null || !EMAIL_PROVIDER.equals(user.getProvider())) { + throw new IllegalArgumentException("Password reset is only available for email-registered users."); + } + // In production, send a reset token via email. + logger.info("Password reset requested at {}", java.time.LocalDateTime.now()); + } + + // Example method representing posting content that requires a verified account + public String postContent(String email, String content) { // NOSONAR + User user = userRepository.findByEmail(email); + if (user == null) { + return "User not found. Please register."; + } + if (!user.isVerified()) { + return "Your account is not verified. Please complete OTP verification. You may request a new OTP after 2 minutes."; + } + logger.info("Content posted successfully by user"); + // For demo purposes, we assume the post is successful. + return "Content posted successfully"; + } + + private int calculateAge(LocalDate birthdate) { + return Period.between(birthdate, LocalDate.now()).getYears(); + } +} diff --git a/src/main/java/com/safetypin/authentication/service/OTPService.java b/src/main/java/com/safetypin/authentication/service/OTPService.java new file mode 100644 index 0000000000000000000000000000000000000000..b338aca55d7c58021ce5e648fa48ce1cf362d8dd --- /dev/null +++ b/src/main/java/com/safetypin/authentication/service/OTPService.java @@ -0,0 +1,47 @@ +package com.safetypin.authentication.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class OTPService { + + private static final long OTP_EXPIRATION_SECONDS = 120; // 2 minutes expiration + private static final Logger log = LoggerFactory.getLogger(OTPService.class); + private final ConcurrentHashMap<String, OTPDetails> otpStorage = new ConcurrentHashMap<>(); + private final SecureRandom random = new SecureRandom(); + + public String generateOTP(String email) { + String otp = String.format("%06d", random.nextInt(1000000)); + OTPDetails details = new OTPDetails(otp, LocalDateTime.now()); + otpStorage.put(email, details); + // Simulate sending OTP via email (in production, integrate with an email service) + log.info("Sending OTP {} to {}", otp, email); + return otp; + } + + public boolean verifyOTP(String email, String otp) { + OTPDetails details = otpStorage.get(email); + if (details == null) { + return false; + } + // Check if OTP has expired + if (details.generatedAt().plusSeconds(OTP_EXPIRATION_SECONDS).isBefore(LocalDateTime.now())) { + otpStorage.remove(email); + return false; + } + if (details.otp().equals(otp)) { + otpStorage.remove(email); + return true; + } + return false; + } + + private record OTPDetails(String otp, LocalDateTime generatedAt) { + } +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index a0f2b58a7fb614b52250942544d45fe65ce53a0c..b7c904d50fe81e4261f1b3a6d8da9bc3c7e217cf 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,8 +1,8 @@ spring.application.name=authentication -spring.datasource.url=${DB_URL} -spring.datasource.username=${DB_PASSWORD} -spring.datasource.password=${DB_PASSWORD} +spring.datasource.url=jdbc:postgresql://localhost:5432/be-authentication +spring.datasource.username=postgres +spring.datasource.password=postgres spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect diff --git a/src/main/resources/application-staging.properties b/src/main/resources/application-staging.properties index c12917add41ed81cc9e56817d979dd07c7db729e..8491638fcdc38630d4c9b9b2ffafac01f9774c36 100644 --- a/src/main/resources/application-staging.properties +++ b/src/main/resources/application-staging.properties @@ -1 +1,12 @@ -spring.application.name=authentication \ No newline at end of file +spring.application.name=authentication + +spring.datasource.url=${JDBC_STAGING_DATABASE_URL} +spring.datasource.username=${JDBC_STAGING_DATABASE_USERNAME} +spring.datasource.password=${JDBC_STAGING_DATABASE_PASSWORD} + +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect + +# Hibernate Properties +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c12917add41ed81cc9e56817d979dd07c7db729e..7087d052396c6c6e70c607393a2065ea43c6fec0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,2 @@ -spring.application.name=authentication \ No newline at end of file +spring.application.name=authentication +spring.profiles.active=${PRODUCTION:dev} \ No newline at end of file diff --git a/src/test/java/com/safetypin/authentication/AuthenticationApplicationTests.java b/src/test/java/com/safetypin/authentication/AuthenticationApplicationTests.java index 599099f38578d629b37983ce7119b66c97b2915b..a528070135724a4e7a25ed890042dfd3bcf6b5cc 100644 --- a/src/test/java/com/safetypin/authentication/AuthenticationApplicationTests.java +++ b/src/test/java/com/safetypin/authentication/AuthenticationApplicationTests.java @@ -1,13 +1,13 @@ package com.safetypin.authentication; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -@SpringBootTest -class AuthenticationApplicationTests { +class AuthenticationApplicationTest { @Test - void contextLoads() { + void testMainDoesNotThrowException() { + // Calling the main method should load the context without throwing an exception. + assertDoesNotThrow(() -> AuthenticationApplication.main(new String[] {})); } - } diff --git a/src/test/java/com/safetypin/authentication/HelloControllerTest.java b/src/test/java/com/safetypin/authentication/HelloControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..cb308d4ee48d94b9afc7856f1d598ef9a30217a3 --- /dev/null +++ b/src/test/java/com/safetypin/authentication/HelloControllerTest.java @@ -0,0 +1,15 @@ +package com.safetypin.authentication; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HelloControllerTest { + + private final HelloController helloController = new HelloController(); + + @Test + void testSayHelloReturnsHelloWorld() { + String greeting = helloController.sayHello(); + assertEquals("Hello, World!", greeting); + } +} diff --git a/src/test/java/com/safetypin/authentication/controller/AuthenticationControllerTest.java b/src/test/java/com/safetypin/authentication/controller/AuthenticationControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..6ecff60c1642d9912d22e5e55e589914d83df9cd --- /dev/null +++ b/src/test/java/com/safetypin/authentication/controller/AuthenticationControllerTest.java @@ -0,0 +1,216 @@ +package com.safetypin.authentication.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.safetypin.authentication.dto.PasswordResetRequest; +import com.safetypin.authentication.dto.RegistrationRequest; +import com.safetypin.authentication.dto.SocialLoginRequest; +import com.safetypin.authentication.model.User; +import com.safetypin.authentication.service.AuthenticationService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; + +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(AuthenticationController.class) +@Import({AuthenticationControllerTest.TestConfig.class, AuthenticationControllerTest.TestSecurityConfig.class}) +class AuthenticationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private AuthenticationService authenticationService; + + @Autowired + private ObjectMapper objectMapper; + + @TestConfiguration + static class TestConfig { + @Bean + public AuthenticationService authenticationService() { + return Mockito.mock(AuthenticationService.class); + } + } + + @TestConfiguration + static class TestSecurityConfig { + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()); + return http.build(); + } + } + + + @Test + void testRegisterEmail() throws Exception { + RegistrationRequest request = new RegistrationRequest(); + request.setEmail("email@example.com"); + request.setPassword("password"); + request.setName("Test User"); + request.setBirthdate(LocalDate.now().minusYears(20)); + + User user = new User(); + user.setEmail("email@example.com"); + user.setPassword("encodedPassword"); + user.setName("Test User"); + user.setRole("USER"); + user.setBirthdate(request.getBirthdate()); + user.setProvider("EMAIL"); + + user.setId(1L); + Mockito.when(authenticationService.registerUser(any(RegistrationRequest.class))).thenReturn(user); + + mockMvc.perform(post("/api/auth/register-email") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1L)) + .andExpect(jsonPath("$.data.email").value("email@example.com")); + } + + @Test + void testRegisterSocial() throws Exception { + SocialLoginRequest request = new SocialLoginRequest(); + request.setProvider("GOOGLE"); + request.setSocialToken("token"); + request.setEmail("social@example.com"); + request.setName("Social User"); + request.setBirthdate(LocalDate.now().minusYears(25)); + request.setSocialId("social123"); + + User user = new User(); + user.setEmail("social@example.com"); + user.setPassword(null); + user.setName("Social User"); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(request.getBirthdate()); + user.setProvider("GOOGLE"); + user.setSocialId("social123"); + user.setId(2L); + Mockito.when(authenticationService.socialLogin(any(SocialLoginRequest.class))).thenReturn(user); + + mockMvc.perform(post("/api/auth/register-social") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(2L)) + .andExpect(jsonPath("$.data.email").value("social@example.com")); + } + + @Test + void testLoginEmail() throws Exception { + User user = new User(); + user.setEmail("email@example.com"); + user.setPassword("encodedPassword"); + user.setName("Test User"); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(20)); + user.setProvider("EMAIL"); + user.setSocialId(null); + + user.setId(1L); + Mockito.when(authenticationService.loginUser("email@example.com", "password")).thenReturn(user); + + mockMvc.perform(post("/api/auth/login-email") + .param("email", "email@example.com") + .param("password", "password")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.email").value("email@example.com")); + } + + @Test + void testLoginSocial() throws Exception { + User user = new User(); + user.setEmail("social@example.com"); + user.setPassword(null); + user.setName("Social User"); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(25)); + user.setProvider("GOOGLE"); + user.setSocialId("social123"); + user.setId(2L); + Mockito.when(authenticationService.loginSocial("social@example.com")).thenReturn(user); + + mockMvc.perform(post("/api/auth/login-social") + .param("email", "social@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(2L)) + .andExpect(jsonPath("$.email").value("social@example.com")); + } + + @Test + void testVerifyOTP_Success() throws Exception { + Mockito.when(authenticationService.verifyOTP("email@example.com", "123456")).thenReturn(true); + + mockMvc.perform(post("/api/auth/verify-otp") + .param("email", "email@example.com") + .param("otp", "123456")) + .andExpect(status().isOk()) + .andExpect(content().string("User verified successfully")); + } + + @Test + void testVerifyOTP_Failure() throws Exception { + Mockito.when(authenticationService.verifyOTP("email@example.com", "000000")).thenReturn(false); + + mockMvc.perform(post("/api/auth/verify-otp") + .param("email", "email@example.com") + .param("otp", "000000")) + .andExpect(status().isOk()) + .andExpect(content().string("OTP verification failed")); + } + + @Test + void testForgotPassword() throws Exception { + PasswordResetRequest request = new PasswordResetRequest(); + request.setEmail("email@example.com"); + + Mockito.doNothing().when(authenticationService).forgotPassword("email@example.com"); + + mockMvc.perform(post("/api/auth/forgot-password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(content().string("Password reset instructions have been sent to your email (simulated)")); + } + + @Test + void testPostContent() throws Exception { + Mockito.when(authenticationService.postContent("email@example.com", "Test Content")) + .thenReturn("Content posted successfully"); + + mockMvc.perform(post("/api/auth/post") + .param("email", "email@example.com") + .param("content", "Test Content")) + .andExpect(status().isOk()) + .andExpect(content().string("Content posted successfully")); + } + + @Test + void testDashboard() throws Exception { + mockMvc.perform(get("/api/auth/dashboard")) + .andExpect(status().isOk()) + .andExpect(content().string("{}")); + } +} diff --git a/src/test/java/com/safetypin/authentication/dto/ErrorResponseTest.java b/src/test/java/com/safetypin/authentication/dto/ErrorResponseTest.java new file mode 100644 index 0000000000000000000000000000000000000000..1398d5a2527655c91f052f4ca71b62f1a3003f55 --- /dev/null +++ b/src/test/java/com/safetypin/authentication/dto/ErrorResponseTest.java @@ -0,0 +1,18 @@ +package com.safetypin.authentication.dto; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import java.time.LocalDateTime; + +class ErrorResponseTest { + + @Test + void testErrorResponseConstructor() { + ErrorResponse errorResponse = new ErrorResponse(404, "Resource not found"); + + assertThat(errorResponse.getStatus()).isEqualTo(404); + assertThat(errorResponse.getMessage()).isEqualTo("Resource not found"); + assertThat(errorResponse.getTimestamp()).isNotNull(); + assertThat(errorResponse.getTimestamp()).isBeforeOrEqualTo(LocalDateTime.now()); + } +} diff --git a/src/test/java/com/safetypin/authentication/dto/PasswordResetRequestTest.java b/src/test/java/com/safetypin/authentication/dto/PasswordResetRequestTest.java new file mode 100644 index 0000000000000000000000000000000000000000..013ec5dcdbecc804e717e1185c093433603614a8 --- /dev/null +++ b/src/test/java/com/safetypin/authentication/dto/PasswordResetRequestTest.java @@ -0,0 +1,37 @@ +package com.safetypin.authentication.dto; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import jakarta.validation.ConstraintViolation; +import java.util.Set; + +class PasswordResetRequestTest { + + private final Validator validator; + + public PasswordResetRequestTest() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + this.validator = factory.getValidator(); + } + + @Test + void testPasswordResetRequestValid() { + PasswordResetRequest request = new PasswordResetRequest(); + request.setEmail("user@example.com"); + + Set<ConstraintViolation<PasswordResetRequest>> violations = validator.validate(request); + assertThat(violations).isEmpty(); + } + + @Test + void testPasswordResetRequestInvalidEmail() { + PasswordResetRequest request = new PasswordResetRequest(); + request.setEmail("invalid-email"); + + Set<ConstraintViolation<PasswordResetRequest>> violations = validator.validate(request); + assertThat(violations).isNotEmpty(); + } +} diff --git a/src/test/java/com/safetypin/authentication/dto/RegistrationRequestTest.java b/src/test/java/com/safetypin/authentication/dto/RegistrationRequestTest.java new file mode 100644 index 0000000000000000000000000000000000000000..b231e6732dc2eba209553a509fac64f1b35e3120 --- /dev/null +++ b/src/test/java/com/safetypin/authentication/dto/RegistrationRequestTest.java @@ -0,0 +1,42 @@ +package com.safetypin.authentication.dto; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import jakarta.validation.ConstraintViolation; +import java.time.LocalDate; +import java.util.Set; + +class RegistrationRequestTest { + + private final Validator validator; + + public RegistrationRequestTest() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + this.validator = factory.getValidator(); + } + + @Test + void testRegistrationRequestValid() { + RegistrationRequest request = new RegistrationRequest(); + request.setEmail("user@example.com"); + request.setPassword("securePassword"); + request.setName("John Doe"); + request.setBirthdate(LocalDate.of(1995, 5, 10)); + + Set<ConstraintViolation<RegistrationRequest>> violations = validator.validate(request); + assertThat(violations).isEmpty(); + } + + @Test + void testRegistrationRequestMissingFields() { + RegistrationRequest request = new RegistrationRequest(); // Missing required fields + + Set<ConstraintViolation<RegistrationRequest>> violations = validator.validate(request); + assertThat(violations) + .isNotEmpty() + .hasSize(4); // Email, password, name, and birthdate should all be invalid + } +} diff --git a/src/test/java/com/safetypin/authentication/dto/SocialLoginRequestTest.java b/src/test/java/com/safetypin/authentication/dto/SocialLoginRequestTest.java new file mode 100644 index 0000000000000000000000000000000000000000..fe4a2a68ff908394936cc3f3b187054577c34d24 --- /dev/null +++ b/src/test/java/com/safetypin/authentication/dto/SocialLoginRequestTest.java @@ -0,0 +1,44 @@ +package com.safetypin.authentication.dto; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import jakarta.validation.ConstraintViolation; +import java.time.LocalDate; +import java.util.Set; + +class SocialLoginRequestTest { + + private final Validator validator; + + public SocialLoginRequestTest() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + this.validator = factory.getValidator(); + } + + @Test + void testSocialLoginRequestValid() { + SocialLoginRequest request = new SocialLoginRequest(); + request.setProvider("GOOGLE"); + request.setSocialToken("validToken"); + request.setEmail("socialuser@example.com"); + request.setName("Social User"); + request.setBirthdate(LocalDate.of(2000, 1, 1)); + request.setSocialId("123456789"); + + Set<ConstraintViolation<SocialLoginRequest>> violations = validator.validate(request); + assertThat(violations).isEmpty(); + } + + @Test + void testSocialLoginRequestMissingFields() { + SocialLoginRequest request = new SocialLoginRequest(); // Missing required fields + + Set<ConstraintViolation<SocialLoginRequest>> violations = validator.validate(request); + assertThat(violations) + .isNotEmpty() + .hasSize(6); // All fields should be invalid + } +} diff --git a/src/test/java/com/safetypin/authentication/model/UserTest.java b/src/test/java/com/safetypin/authentication/model/UserTest.java new file mode 100644 index 0000000000000000000000000000000000000000..0b3c180b9f24c4b15bbe20ff64e5679ac77fd146 --- /dev/null +++ b/src/test/java/com/safetypin/authentication/model/UserTest.java @@ -0,0 +1,93 @@ +package com.safetypin.authentication.model; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +class UserTest { + + @Test + void testDefaultConstructorDefaults() { + User user = new User(); + // Verify that default constructor sets all fields to their default values + assertNull(user.getId(), "Default id should be null"); + assertNull(user.getEmail(), "Default email should be null"); + assertNull(user.getPassword(), "Default password should be null"); + assertNull(user.getName(), "Default name should be null"); + assertFalse(user.isVerified(), "Default isVerified should be false"); + assertNull(user.getRole(), "Default role should be null"); + assertNull(user.getBirthdate(), "Default birthdate should be null"); + assertNull(user.getProvider(), "Default provider should be null"); + assertNull(user.getSocialId(), "Default socialId should be null"); + } + + @Test + void testSettersAndGetters() { + User user = new User(); + Long id = 123L; + String email = "test@example.com"; + String password = "secret"; + String name = "Test User"; + boolean verified = true; + String role = "ADMIN"; + LocalDate birthdate = LocalDate.of(2000, 1, 1); + String provider = "GOOGLE"; + String socialId = "social123"; + + user.setId(id); + user.setEmail(email); + user.setPassword(password); + user.setName(name); + user.setVerified(verified); + user.setRole(role); + user.setBirthdate(birthdate); + user.setProvider(provider); + user.setSocialId(socialId); + + assertEquals(id, user.getId()); + assertEquals(email, user.getEmail()); + assertEquals(password, user.getPassword()); + assertEquals(name, user.getName()); + assertTrue(user.isVerified()); + assertEquals(role, user.getRole()); + assertEquals(birthdate, user.getBirthdate()); + assertEquals(provider, user.getProvider()); + assertEquals(socialId, user.getSocialId()); + } + + @Test + void testParameterizedConstructor() { + String email = "test2@example.com"; + String password = "password123"; + String name = "Another User"; + boolean verified = false; + String role = "USER"; + LocalDate birthdate = LocalDate.of(1995, 5, 15); + String provider = "EMAIL"; + String socialId = null; + + User user = new User(); + user.setEmail(email); + user.setPassword(password); + user.setName(name); + user.setVerified(verified); + user.setRole(role); + user.setBirthdate(birthdate); + user.setProvider(provider); + user.setSocialId(socialId); + + + // id remains null until set (by the persistence layer) + assertNull(user.getId(), "Id should be null when not set"); + assertEquals(email, user.getEmail()); + assertEquals(password, user.getPassword()); + assertEquals(name, user.getName()); + assertEquals(verified, user.isVerified()); + assertEquals(role, user.getRole()); + assertEquals(birthdate, user.getBirthdate()); + assertEquals(provider, user.getProvider()); + assertNull(user.getSocialId(), "SocialId should be null"); + } +} diff --git a/src/test/java/com/safetypin/authentication/repository/UserRepositoryTest.java b/src/test/java/com/safetypin/authentication/repository/UserRepositoryTest.java new file mode 100644 index 0000000000000000000000000000000000000000..fd7e4ff0de472cd55392cdb8ba9b5430095be556 --- /dev/null +++ b/src/test/java/com/safetypin/authentication/repository/UserRepositoryTest.java @@ -0,0 +1,46 @@ +package com.safetypin.authentication.repository; + +import com.safetypin.authentication.model.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @BeforeEach + void setUp() { + userRepository.deleteAll(); + + // Create and save a User entity + User user = new User(); + user.setEmail("test@example.com"); + user.setPassword("password"); + user.setName("Test User"); + user.setRole("USER"); + userRepository.save(user); + } + + @Test + void testFindByEmailWhenUserExists() { + // Retrieve the user by email + User foundUser = userRepository.findByEmail("test@example.com"); + assertNotNull(foundUser, "Expected to find a user with the given email"); + assertEquals("test@example.com", foundUser.getEmail()); + assertEquals("Test User", foundUser.getName()); + } + + @Test + void testFindByEmailWhenUserDoesNotExist() { + // Attempt to find a user that doesn't exist + User foundUser = userRepository.findByEmail("nonexistent@example.com"); + assertNull(foundUser, "Expected no user to be found for a non-existent email"); + } + +} diff --git a/src/test/java/com/safetypin/authentication/seeder/DevDataSeederTest.java b/src/test/java/com/safetypin/authentication/seeder/DevDataSeederTest.java new file mode 100644 index 0000000000000000000000000000000000000000..04e4931d56f4a30d4e83ca5236abc3ae4c4d550d --- /dev/null +++ b/src/test/java/com/safetypin/authentication/seeder/DevDataSeederTest.java @@ -0,0 +1,62 @@ +package com.safetypin.authentication.seeder; + +import com.safetypin.authentication.model.User; +import com.safetypin.authentication.repository.UserRepository; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@ActiveProfiles("dev") // Use the 'dev' profile during tests +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Transactional +class DevDataSeederTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + // Test that the seeder inserts 5 users when no users exist + @Test + void testSeederInsertsUsersWhenEmpty() { + userRepository.deleteAll(); // Ensure the database is empty before seeding + + new DevDataSeeder(userRepository, passwordEncoder).run(); // Run the seeder + + List<User> users = userRepository.findAll(); + assertEquals(5, users.size(), "Seeder should insert 5 users when repository is empty"); + } + + // Test that the seeder does not add any users if at least one user already exists + @Test + void testSeederDoesNotInsertIfUsersExist() { + // Save an existing user into the repository + User user = new User(); + user.setEmail("existing@example.com"); + user.setPassword(passwordEncoder.encode("test")); + user.setName("Existing User"); + user.setVerified(true); + user.setRole("admin"); + user.setBirthdate(LocalDate.of(1990, 1, 1)); + user.setProvider("EMAIL"); + user.setSocialId("social_9999"); + userRepository.save(user); + + long countBefore = userRepository.count(); + new DevDataSeeder(userRepository, passwordEncoder).run(); + long countAfter = userRepository.count(); + + assertEquals(countBefore, countAfter, "Seeder should not insert new users if users already exist"); + } +} diff --git a/src/test/java/com/safetypin/authentication/service/AuthenticationServiceTest.java b/src/test/java/com/safetypin/authentication/service/AuthenticationServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..97b3d107e86541481058024dc228798d75c85308 --- /dev/null +++ b/src/test/java/com/safetypin/authentication/service/AuthenticationServiceTest.java @@ -0,0 +1,436 @@ +package com.safetypin.authentication.service; + +import com.safetypin.authentication.dto.RegistrationRequest; +import com.safetypin.authentication.dto.SocialLoginRequest; +import com.safetypin.authentication.exception.InvalidCredentialsException; +import com.safetypin.authentication.exception.UserAlreadyExistsException; +import com.safetypin.authentication.model.User; +import com.safetypin.authentication.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthenticationServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private OTPService otpService; + + @InjectMocks + private AuthenticationService authenticationService; + + // registerUser tests + + @Test + void testRegisterUser_UnderAge() { + RegistrationRequest request = new RegistrationRequest(); + request.setEmail("test@example.com"); + request.setPassword("password"); + request.setName("Test User"); + // set birthdate to 17 years old + request.setBirthdate(LocalDate.now().minusYears(15)); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> + authenticationService.registerUser(request) + ); + assertEquals("User must be at least 16 years old", exception.getMessage()); + } + + @Test + void testRegisterUser_DuplicateEmail() { + RegistrationRequest request = new RegistrationRequest(); + request.setEmail("test@example.com"); + request.setPassword("password"); + request.setName("Test User"); + request.setBirthdate(LocalDate.now().minusYears(20)); + + when(userRepository.findByEmail("test@example.com")).thenReturn(new User()); + + Exception exception = assertThrows(UserAlreadyExistsException.class, () -> + authenticationService.registerUser(request) + ); + assertTrue(exception.getMessage().contains("Email address is already registered")); + } + + @Test + void testRegisterUser_Success() { + RegistrationRequest request = new RegistrationRequest(); + request.setEmail("test@example.com"); + request.setPassword("password"); + request.setName("Test User"); + request.setBirthdate(LocalDate.now().minusYears(20)); + + when(userRepository.findByEmail("test@example.com")).thenReturn(null); + when(passwordEncoder.encode("password")).thenReturn("encodedPassword"); + User savedUser = new User(); + savedUser.setEmail("test@example.com"); + savedUser.setPassword("encodedPassword"); + savedUser.setName("Test User"); + savedUser.setVerified(false); + savedUser.setRole("USER"); + savedUser.setBirthdate(request.getBirthdate()); + savedUser.setProvider("EMAIL"); + savedUser.setSocialId(null); + + savedUser.setId(1L); + when(userRepository.save(any(User.class))).thenReturn(savedUser); + + User result = authenticationService.registerUser(request); + assertNotNull(result); + assertEquals("test@example.com", result.getEmail()); + // OTPService should be invoked to generate OTP. + verify(otpService, times(1)).generateOTP("test@example.com"); + } + + // socialLogin tests + + @Test + void testSocialLogin_UnderAge() { + SocialLoginRequest request = new SocialLoginRequest(); + request.setEmail("social@example.com"); + request.setName("Social User"); + request.setBirthdate(LocalDate.now().minusYears(15)); + request.setProvider("GOOGLE"); + request.setSocialId("social123"); + request.setSocialToken("token"); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> + authenticationService.socialLogin(request) + ); + assertEquals("User must be at least 16 years old", exception.getMessage()); + } + + @Test + void testSocialLogin_DuplicateEmailWithEmailProvider() { + SocialLoginRequest request = new SocialLoginRequest(); + request.setEmail("social@example.com"); + request.setName("Social User"); + request.setBirthdate(LocalDate.now().minusYears(25)); + request.setProvider("APPLE"); + request.setSocialId("social123"); + request.setSocialToken("token"); + + User existingUser = new User(); + existingUser.setEmail("social@example.com"); + existingUser.setPassword("encodedPassword"); + existingUser.setName("Existing User"); + existingUser.setVerified(false); + existingUser.setRole("USER"); + existingUser.setBirthdate(LocalDate.now().minusYears(30)); + existingUser.setProvider("EMAIL"); + existingUser.setSocialId(null); + + when(userRepository.findByEmail("social@example.com")).thenReturn(existingUser); + + Exception exception = assertThrows(UserAlreadyExistsException.class, () -> + authenticationService.socialLogin(request) + ); + assertTrue(exception.getMessage().contains("An account with this email exists")); + } + + @Test + void testSocialLogin_ExistingSocialUser() { + SocialLoginRequest request = new SocialLoginRequest(); + request.setEmail("social@example.com"); + request.setName("Social User"); + request.setBirthdate(LocalDate.now().minusYears(25)); + request.setProvider("GOOGLE"); + request.setSocialId("social123"); + request.setSocialToken("token"); + + User existingUser = new User(); + existingUser.setEmail("social@example.com"); + existingUser.setPassword(null); + existingUser.setName("Social User"); + existingUser.setVerified(true); + existingUser.setRole("USER"); + existingUser.setBirthdate(LocalDate.now().minusYears(25)); + existingUser.setProvider("GOOGLE"); + existingUser.setSocialId("social123"); + + when(userRepository.findByEmail("social@example.com")).thenReturn(existingUser); + + User result = authenticationService.socialLogin(request); + assertNotNull(result); + assertEquals("social@example.com", result.getEmail()); + } + + @Test + void testSocialLogin_NewUser() { + SocialLoginRequest request = new SocialLoginRequest(); + request.setEmail("social@example.com"); + request.setName("Social User"); + request.setBirthdate(LocalDate.now().minusYears(25)); + request.setProvider("GOOGLE"); + request.setSocialId("social123"); + request.setSocialToken("token"); + + when(userRepository.findByEmail("social@example.com")).thenReturn(null); + User savedUser = new User(); + savedUser.setEmail("social@example.com"); + savedUser.setPassword(null); + savedUser.setName("Social User"); + savedUser.setVerified(true); + savedUser.setRole("USER"); + savedUser.setBirthdate(request.getBirthdate()); + savedUser.setProvider("GOOGLE"); + savedUser.setSocialId("social123"); + + savedUser.setId(2L); + when(userRepository.save(any(User.class))).thenReturn(savedUser); + + User result = authenticationService.socialLogin(request); + assertNotNull(result); + assertEquals("social@example.com", result.getEmail()); + } + + // loginUser tests + + @Test + void testLoginUser_EmailNotFound() { + when(userRepository.findByEmail("notfound@example.com")).thenReturn(null); + Exception exception = assertThrows(InvalidCredentialsException.class, () -> + authenticationService.loginUser("notfound@example.com", "password") + ); + assertTrue(exception.getMessage().contains("Invalid email")); + } + + @Test + void testLoginUser_InvalidPassword_NullPassword() { + User user = new User(); + user.setEmail("test@example.com"); + user.setPassword(null); + user.setName("Test User"); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(20)); + user.setProvider("EMAIL"); + user.setSocialId(null); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + + Exception exception = assertThrows(InvalidCredentialsException.class, () -> + authenticationService.loginUser("test@example.com", "password") + ); + assertTrue(exception.getMessage().contains("Invalid password")); + } + + @Test + void testLoginUser_InvalidPassword_WrongMatch() { + User user = new User(); + user.setEmail("test@example.com"); + user.setPassword("encodedPassword"); + user.setName("Test User"); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(20)); + user.setProvider("EMAIL"); + user.setSocialId(null); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + when(passwordEncoder.matches("wrongPassword", "encodedPassword")).thenReturn(false); + + Exception exception = assertThrows(InvalidCredentialsException.class, () -> + authenticationService.loginUser("test@example.com", "wrongPassword") + ); + assertTrue(exception.getMessage().contains("Invalid password")); + } + + @Test + void testLoginUser_Success() { + User user = new User(); + user.setEmail("test@example.com"); + user.setPassword("encodedPassword"); + user.setName("Test User"); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(20)); + user.setProvider("EMAIL"); + user.setSocialId(null); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + when(passwordEncoder.matches("password", "encodedPassword")).thenReturn(true); + + User result = authenticationService.loginUser("test@example.com", "password"); + assertNotNull(result); + assertEquals("test@example.com", result.getEmail()); + } + + // loginSocial tests + + @Test + void testLoginSocial_UserNotFound() { + when(userRepository.findByEmail("notfound@example.com")).thenReturn(null); + Exception exception = assertThrows(InvalidCredentialsException.class, () -> + authenticationService.loginSocial("notfound@example.com") + ); + assertTrue(exception.getMessage().contains("Social login failed")); + } + + @Test + void testLoginSocial_Success() { + User user = new User(); + user.setEmail("social@example.com"); + user.setPassword(null); + user.setName("Social User"); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(25)); + user.setProvider("GOOGLE"); + user.setSocialId("social123"); + + when(userRepository.findByEmail("social@example.com")).thenReturn(user); + + User result = authenticationService.loginSocial("social@example.com"); + assertNotNull(result); + assertEquals("social@example.com", result.getEmail()); + } + + // verifyOTP tests + + @Test + void testVerifyOTP_Success() { + // OTPService returns true and user is found + when(otpService.verifyOTP("test@example.com", "123456")).thenReturn(true); + User user = new User(); + user.setEmail("test@example.com"); + user.setPassword("encodedPassword"); + user.setName("Test User"); + user.setVerified(false); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(20)); + user.setProvider("EMAIL"); + user.setSocialId(null); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + when(userRepository.save(any(User.class))).thenReturn(user); + + boolean result = authenticationService.verifyOTP("test@example.com", "123456"); + assertTrue(result); + assertTrue(user.isVerified()); + verify(userRepository, times(1)).save(user); + } + + @Test + void testVerifyOTP_Success_UserNotFound() { + // OTPService returns true but user is not found + when(otpService.verifyOTP("nonexistent@example.com", "123456")).thenReturn(true); + when(userRepository.findByEmail("nonexistent@example.com")).thenReturn(null); + + boolean result = authenticationService.verifyOTP("nonexistent@example.com", "123456"); + assertTrue(result); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void testVerifyOTP_Failure() { + when(otpService.verifyOTP("test@example.com", "000000")).thenReturn(false); + boolean result = authenticationService.verifyOTP("test@example.com", "000000"); + assertFalse(result); + verify(userRepository, never()).save(any(User.class)); + } + + // forgotPassword tests + + @Test + void testForgotPassword_Success() { + User user = new User(); + user.setEmail("test@example.com"); + user.setPassword("encodedPassword"); + user.setName("Test User"); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(20)); + user.setProvider("EMAIL"); + user.setSocialId(null); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + + assertDoesNotThrow(() -> authenticationService.forgotPassword("test@example.com")); + } + + @Test + void testForgotPassword_Invalid() { + // Case 1: user not found + when(userRepository.findByEmail("notfound@example.com")).thenReturn(null); + Exception exception1 = assertThrows(IllegalArgumentException.class, () -> + authenticationService.forgotPassword("notfound@example.com") + ); + assertTrue(exception1.getMessage().contains("Password reset is only available for email-registered users.")); + + // Case 2: user exists but provider is not EMAIL + User user = new User(); + user.setEmail("social@example.com"); + user.setPassword(null); + user.setName("Social User"); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(25)); + user.setProvider("GOOGLE"); + user.setSocialId("social123"); + + when(userRepository.findByEmail("social@example.com")).thenReturn(user); + Exception exception2 = assertThrows(IllegalArgumentException.class, () -> + authenticationService.forgotPassword("social@example.com") + ); + assertTrue(exception2.getMessage().contains("Password reset is only available for email-registered users.")); + } + + // postContent tests + + @Test + void testPostContent_UserNotFound() { + when(userRepository.findByEmail("notfound@example.com")).thenReturn(null); + String response = authenticationService.postContent("notfound@example.com", "Content"); + assertEquals("User not found. Please register.", response); + } + + @Test + void testPostContent_UserNotVerified() { + User user = new User(); + user.setEmail("test@example.com"); + user.setPassword("encodedPassword"); + user.setName("Test User"); + user.setVerified(false); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(20)); + user.setProvider("EMAIL"); + user.setSocialId(null); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + String response = authenticationService.postContent("test@example.com", "Content"); + assertTrue(response.contains("not verified")); + } + + @Test + void testPostContent_UserVerified() { + User user = new User(); + user.setEmail("test@example.com"); + user.setPassword("encodedPassword"); + user.setName("Test User"); + user.setVerified(true); + user.setRole("USER"); + user.setBirthdate(LocalDate.now().minusYears(20)); + user.setProvider("EMAIL"); + user.setSocialId(null); + + when(userRepository.findByEmail("test@example.com")).thenReturn(user); + String response = authenticationService.postContent("test@example.com", "Content"); + assertEquals("Content posted successfully", response); + } +} diff --git a/src/test/java/com/safetypin/authentication/service/OTPServiceTest.java b/src/test/java/com/safetypin/authentication/service/OTPServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..cfca094d53014f45858935dfa0f1c97eb354ac48 --- /dev/null +++ b/src/test/java/com/safetypin/authentication/service/OTPServiceTest.java @@ -0,0 +1,80 @@ +package com.safetypin.authentication.service; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.time.LocalDateTime; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.*; + +class OTPServiceTest { + + @Test + void testGenerateOTP() { + OTPService otpService = new OTPService(); + String email = "user@example.com"; + String otp = otpService.generateOTP(email); + assertNotNull(otp, "OTP should not be null"); + assertEquals(6, otp.length(), "OTP should be 6 characters long"); + assertTrue(otp.matches("\\d{6}"), "OTP should consist of 6 digits"); + } + + @Test + void testVerifyOTPSuccess() { + OTPService otpService = new OTPService(); + String email = "user@example.com"; + String otp = otpService.generateOTP(email); + // Immediately verify the generated OTP; it should succeed. + boolean result = otpService.verifyOTP(email, otp); + assertTrue(result, "The OTP should verify successfully"); + } + + @Test + void testVerifyOTPWrongOtp() { + OTPService otpService = new OTPService(); + String email = "user@example.com"; + otpService.generateOTP(email); + // Try verifying with an incorrect OTP. + boolean result = otpService.verifyOTP(email, "000000"); + assertFalse(result, "Verification should fail for an incorrect OTP"); + } + + @Test + void testVerifyOTPExpired() throws Exception { + OTPService otpService = new OTPService(); + String email = "user@example.com"; + String otp = otpService.generateOTP(email); + + // Access the private otpStorage field via reflection. + java.lang.reflect.Field otpStorageField = OTPService.class.getDeclaredField("otpStorage"); + otpStorageField.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentHashMap<String, Object> otpStorage = (ConcurrentHashMap<String, Object>) otpStorageField.get(otpService); + + // Retrieve the current OTPDetails instance. + Object oldOtpDetails = otpStorage.get(email); + assertNotNull(oldOtpDetails, "OTPDetails instance should exist"); + + // Use reflection to get the private constructor of OTPDetails. + Class<?> otpDetailsClass = oldOtpDetails.getClass(); + Constructor<?> constructor = otpDetailsClass.getDeclaredConstructor(String.class, LocalDateTime.class); + constructor.setAccessible(true); + // Create a new OTPDetails instance with an expired time (3 minutes ago). + Object expiredOtpDetails = constructor.newInstance(otp, LocalDateTime.now().minusMinutes(3)); + // Replace the old OTPDetails with the expired one. + otpStorage.put(email, expiredOtpDetails); + + // Now verification should fail because the OTP is expired. + boolean result = otpService.verifyOTP(email, otp); + assertFalse(result, "The OTP should be expired and verification should fail"); + } + + @Test + void testVerifyOTPWhenNotGenerated() { + OTPService otpService = new OTPService(); + // No OTP was generated for this email, so verification should return false. + boolean result = otpService.verifyOTP("nonexistent@example.com", "123456"); + assertFalse(result, "Verification should fail when no OTP is generated for the given email"); + } +} diff --git a/src/test/resources/application-dev.properties b/src/test/resources/application-dev.properties new file mode 100644 index 0000000000000000000000000000000000000000..e42e68fd7cebc1b0e033fbdaadfad2e93ffa1545 --- /dev/null +++ b/src/test/resources/application-dev.properties @@ -0,0 +1,14 @@ +# src/test/resources/application-dev.properties + +# Configure H2 in-memory database +spring.datasource.url=jdbc:h2:mem:devdb;DB_CLOSE_DELAY=-1 +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# JPA/Hibernate configurations +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop + +# (Optional) Enable SQL logging +spring.jpa.show-sql=true diff --git a/staging.yaml b/staging.yaml new file mode 100644 index 0000000000000000000000000000000000000000..91457e76499c95f3a4b2718dfaf62f90a0dda88b --- /dev/null +++ b/staging.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: authentication +spec: + type: LoadBalancer + selector: + app: authentication + ports: + - port: 80 + targetPort: 8080 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: authentication + labels: + app: authentication +spec: + replicas: 1 + selector: + matchLabels: + app: authentication + template: + metadata: + labels: + app: authentication + spec: + containers: + - name: authentication + image: us-central1-docker.pkg.dev/GOOGLE_PROJECT/staging-repository/authentication:latest + ports: + - containerPort: 8080 \ No newline at end of file