diff --git a/LICENSE.md b/LICENSE.md
deleted file mode 100644
index 62da4936..00000000
--- a/LICENSE.md
+++ /dev/null
@@ -1 +0,0 @@
-Apache 2.0
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 00000000..779dbb71
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,13 @@
+Copyright 2020-2021 the original author or authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this artifact or file except in compliance with the License.
+You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/README.md b/README.md
index 51d73cd1..347b80f6 100644
--- a/README.md
+++ b/README.md
@@ -1,51 +1,80 @@
# Spring Lemon
-When developing **real-world** Spring REST services for JavaScript web applications, mobile clients or any consumer, you face many challenges, such as
+> Note: for latest patterns and best practices, follow the successor https://github.com/naturalprogrammer/np-spring-mvc-demo
-1. How to make the API truly _stateless_, using token authentication, session sliding etc.
+When developing **real-world** Spring REST APIs and microservices, you face many challenges like
+
+1. How to follow a stateless and efficient security model – using JWT authentication, session sliding etc.
1. How to configure Spring Security to suit API development, e.g. returning _200_ or _401_ responses on login, configuring _CORS_, _JSON vulnerability protection_, etc.
-1. How to handle _validations_ and _exceptions_ in a cross functional manner and send precise errors to the client.
+1. How to elegantly do _validations_ and _exceptions_ and send precise errors to the client.
+1. How to easily mix manual and bean validations in a single validation cycle.
1. How exactly to support multiple _social sign up/in_, using _OpenID Connect_ or _OAuth2_ providers such as _Google_ and _Facebook_.
1. How to code a robust user module (with features like _sign up_, _sign in_, _verify email_, _social sign up/in_, _update profile_, _forgot password_, _change password_, _change email_, _token authentication_ etc.) and share it across all your applications.
-1. _What would be good ways to test your API_.
+1. How to correctly and effeciently _secure microservices_, using long-lived and short-lived JWTs.
+1. What would be good ways to _test your API_.
1. How to do _Captcha validation_.
1. How to properly organize _application properties_.
1. How to use _PATCH_ and _JsonPatch_ to handle partial updates correctly.
+1. How to do all the above reactively, using WebFlux and WebFlux security.
-Coding all the above effectively needs in-depth knowledge of Spring. It also takes a lot of development time and effort, and needs to be properly maintained as new versions of Spring modules come out.
+Coding all this rightly needs in-depth knowledge of Spring. It also takes a lot of development time and effort, and needs to be properly maintained as new versions of Spring comes out.
-To relieve you of this non-trivial job, we thought to bring out **Spring Lemon**, a tiny open source library holding all these common configuration and components, and a production grade user module with all the abovementioned features.
+**Spring Lemon** relieves you of all this burden. It's a set of configurable and extensible libraries, providing all above features. Use these to develop quality reactive or non-reactive monolith or microservices applications quickly and easily.
-Even if you don't plan to use Spring Lemon, it's a good example application to learn from, because it showcases the essential best practices for developing elegant web services using Spring.
+Even if you don't plan to use Spring Lemon, it's a good example to learn from, because it showcases the essential best practices for developing elegant web services and microservices using Spring.
Most Spring Boot applications can use Spring Lemon straight away, with some simple configurations. But, if you don't find it suitable for your application, feel free to fork it, or just roll out your own library by learning its patterns and practices. Better yet, be a contributor!
-Watch [this video tutorial](https://www.naturalprogrammer.com/p/spring-lemon-restful-web-services-development) or read [this quick starter guide](https://github.com/naturalprogrammer/spring-lemon/wiki/Getting-Started-With-Spring-Lemon) for getting started.
+Read [this quick starter guide](https://github.com/naturalprogrammer/spring-lemon/wiki/Getting-Started-With-Spring-Lemon) or watch [this video tutorial](https://www.naturalprogrammer.com/p/spring-lemon-restful-web-services-development) for getting started.
+
+## Libraries hierarchy
+
+* [spring-lemon-exceptions](https://github.com/naturalprogrammer/spring-lemon/wiki/Spring-Lemon-Exceptions-Guide): Useful for elegant exception handling and validation in any Spring project
+ * [spring-lemon-commons](https://github.com/naturalprogrammer/spring-lemon/wiki/Spring-Lemon-Commons-Guide): Common for all things below
+ * [spring-lemon-commons-web](https://github.com/naturalprogrammer/spring-lemon/wiki/Spring-Lemon-Commons-Web-Guide): For developing Spring Web (non-reactive) microservices
+ * [spring-lemon-commons-jpa](https://github.com/naturalprogrammer/spring-lemon/wiki/Spring-Lemon-Commons-JPA-Guide): For developing Spring Web (non-reactive) JPA microservices
+ * [spring-lemon-jpa](https://github.com/naturalprogrammer/spring-lemon/wiki/Spring-Lemon-JPA-Guide): For developing Spring Web (non-reactive) JPA monolith or auth-microservice
+ * [spring-lemon-commons-reactive](https://github.com/naturalprogrammer/spring-lemon/wiki/Spring-Lemon-Commons-Reactive-Guide): For developing Spring WebFlux (reactive) microservices
+ * [spring-lemon-commons-mongo](https://github.com/naturalprogrammer/spring-lemon/wiki/Spring-Lemon-Commons-MongoDB-Guide): For developing Spring WebFlux (reactive) MongoDB microservices
+ * [spring-lemon-reactive](https://github.com/naturalprogrammer/spring-lemon/wiki/Spring-Lemon-Reactive-Guide): For developing Spring WebFlux (reactive) MongoDB monolith or auth-microservice
+
+For example usages, see
+
+* [Demo non-reactive monolith](https://github.com/naturalprogrammer/spring-lemon/tree/master/lemon-demo-jpa)
+* [Demo reactive monolith](https://github.com/naturalprogrammer/spring-lemon/tree/master/lemon-demo-reactive)
+* [Demo non-reactive microservices](https://github.com/naturalprogrammer/np-microservices-sample-01) and its [configuration repository](https://github.com/naturalprogrammer/np-microservices-sample-01-config)
+* [Demo reactive microservices](https://github.com/naturalprogrammer/np-microservices-sample-02) and its [configuration repository](https://github.com/naturalprogrammer/np-microservices-sample-02-config)
## Documentation and Resources
-> _Part I of our [Mastering Spring RESTful Web Services Development](https://www.naturalprogrammer.com/p/spring-restful-web-services-tutorial-i) — the ultimate course to master REST API development using Spring whether you use Spring Lemon or not — is now available for FREE._
+> _Our [Spring Framework Recipes For Real World Application Development](https://www.naturalprogrammer.com/p/spring-framework-book-of-best-practices) — a live book discussing key real-world topics on developing Spring applications and APIs — is now available for FREE. [Click here](https://www.naturalprogrammer.com/p/spring-framework-book-of-best-practices) to get it._
1. Feature demo: https://youtu.be/6mNg-Feq8CY
1. _Getting started guide_
1. [Book](https://github.com/naturalprogrammer/spring-lemon/wiki/Getting-Started-With-Spring-Lemon)
1. [Video Tutorial](https://www.naturalprogrammer.com/p/spring-lemon-restful-web-services-development)
-1. _[Example application](https://github.com/naturalprogrammer/lemon-demo)_ — A sample application using Spring Lemon. Quite similar to the one developed in the above [getting started guide](https://github.com/naturalprogrammer/spring-lemon/wiki/Getting-Started-With-Spring-Lemon), but additionally has automated tests.
-1. _[API documentation](https://documenter.getpostman.com/view/305915/RVu2mqEH)_ of the above application.
-1. Spring Lemon [JavaDoc](https://naturalprogrammer.github.io/javadoc/spring-lemon/1.0.0.m2/)
-1. _[Example Angular 1.x front-end application](https://github.com/naturalprogrammer/lemon-demo-angular1)_ — A sample AngularJS 1.x front-end. It'll work both for the application developed in the above [getting started guide](https://documenter.getpostman.com/view/305915/lemondemo/RVnPL46k) as well as the [Lemon Demo application](https://github.com/naturalprogrammer/lemon-demo).
-1. _Mastering Spring RESTful Web Services Development_ — Ultimate course to master REST API development using Spring. Also covers Spring Lemon in depth. A must guide if you want to become an expert Spring developer, whether you use Spring Lemon or not. [Click here](https://www.naturalprogrammer.com/p/spring-restful-web-services-tutorial-i) to get part I now for FREE!
+1. _[Official Documentation](https://github.com/naturalprogrammer/spring-lemon/wiki)_
+1. _Example applications_
+ * [Demo non-reactive monolith](https://github.com/naturalprogrammer/spring-lemon/tree/master/lemon-demo-jpa)
+ * [Demo reactive monolith](https://github.com/naturalprogrammer/spring-lemon/tree/master/lemon-demo-reactive)
+ * [Demo non-reactive microservices](https://github.com/naturalprogrammer/np-microservices-sample-01) and its [configuration repository](https://github.com/naturalprogrammer/np-microservices-sample-01-config)
+ * [Demo reactive microservices](https://github.com/naturalprogrammer/np-microservices-sample-02) and its [configuration repository](https://github.com/naturalprogrammer/np-microservices-sample-02-config)
+1. _[API documentation](https://documenter.getpostman.com/view/305915/RVu2mqEH)_ of the above applications.
+1. _[Example AngularJS front-end application](https://github.com/naturalprogrammer/spring-lemon/tree/master/lemon-demo-angularjs)_ — A sample AngularJS 1.x front-end. It'll work for the application developed in the above [getting started guide](https://github.com/naturalprogrammer/spring-lemon/wiki/Getting-Started-With-Spring-Lemon) as well all the above example applications. See the [Getting Started Guide](https://github.com/naturalprogrammer/spring-lemon/wiki/Getting-Started-With-Spring-Lemon) on how to use it.
+1. _[Spring Framework Recipes For Real World Application Development](https://www.naturalprogrammer.com/p/spring-framework-book-of-best-practices)_ — a live book discussing key real-world topics on developing Spring applications, APIs and microservoces. Includes many Spring Lemon topics. [Click here](https://www.naturalprogrammer.com/p/spring-framework-book-of-best-practices) to get it now for FREE!
+1. [Using Spring Lemon Effectively](https://github.com/naturalprogrammer/spring-lemon/wiki/Using-Spring-Lemon-Effectively)
+1. [DZone Articles](https://dzone.com/users/1211183/skpatel20.html)
+1. Video tutorials coming soon:
+ 1. Spring Framework 5 REST API Development — A Complete Blueprint For Real-World Developers
+ 1. Spring WebFlux Reactive REST API Development — A Complete Blueprint For Real-World Developers
+ 1. Microservices Using Spring Cloud — A Rapid Course For Real World Developers
+ 1. Join [here](https://www.naturalprogrammer.com/p/spring-framework-book-of-best-practices) to get notified and avail heavy discounts when the above courses get released
## Help and Support
1. Community help is available at [stackoverflow.com](http://stackoverflow.com/questions/tagged/spring-lemon), under the `spring-lemon` tag. Do not miss to tag the questions with `spring-lemon`!
1. [Submit an issue](https://github.com/naturalprogrammer/spring-lemon/issues) for any bug or enhancement. Please check first that the issue isn't already reported earlier.
-1. Mentoring, training and professional help is provided by [naturalprogrammer.com](http://www.naturalprogrammer.com/consulting/).
-
-## Donate
-Like Spring Lemon? We have been putting continuous efforts to develop and maintain it. If it's being useful to you, why not donate a little amount — it’ll help us give more time to the project!
-
-[Click here](http://www.naturalprogrammer.com/support-spring-lemon/) to donate.
+1. Mentoring, training and professional help is provided by [naturalprogrammer.com](https://www.naturalprogrammer.com).
-## Releases
+## Releases and Breaking Changes
1. See [here](https://github.com/naturalprogrammer/spring-lemon/releases).
diff --git a/lemon-demo-angularjs/app/scripts/services/formservice.js b/lemon-demo-angularjs/app/scripts/services/formservice.js
index 83dccd02..c4127020 100644
--- a/lemon-demo-angularjs/app/scripts/services/formservice.js
+++ b/lemon-demo-angularjs/app/scripts/services/formservice.js
@@ -10,7 +10,16 @@
angular.module('appBoot')
.factory('formService', function($http, $location, alerts) {
- /**
+ var serialize = function(obj) {
+ var str = [];
+ for (var p in obj)
+ if (obj.hasOwnProperty(p)) {
+ str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p]));
+ }
+ return str.join("&");
+ }
+
+ /**
* Creates the array if not already present
*
* @param obj
@@ -30,6 +39,8 @@ angular.module('appBoot')
switch (errorData.exception) {
case 'ConstraintViolationException': // server side JSR-303 errors
+ case 'ExplicitConstraintViolationException': // server side JSR-303 errors
+ case 'WebExchangeBindException': // server side JSR-303 errors
case 'MultiErrorException': // server side form errors manually thrown
angular.forEach(errorData.errors, function(error) { // for each fields
@@ -72,7 +83,7 @@ angular.module('appBoot')
if (method === 'post' || method === 'put' || method === 'patch') {
if (options.asParam) // data should be sent as params
- return $http[method](serverUrl + url, null, {params: options.data});
+ return $http[method](serverUrl + url, serialize(options.data), {headers: {'Content-Type': 'application/x-www-form-urlencoded'}});
return $http[method](serverUrl + url, options.data);
}
diff --git a/lemon-demo-angularjs/app/scripts/services/user-service.js b/lemon-demo-angularjs/app/scripts/services/user-service.js
index 65112e6a..7f06c10e 100644
--- a/lemon-demo-angularjs/app/scripts/services/user-service.js
+++ b/lemon-demo-angularjs/app/scripts/services/user-service.js
@@ -24,8 +24,8 @@ angular.module('angularSampleApp')
this.goodUser = !(this.unverified || this.blocked);
this.goodAdmin = this.admin && this.goodUser;
- this.editable = authService.isAuthenticated() && (this.id === authService.user.id || authService.isGoodAdmin());
- this.rolesEditable = authService.isGoodAdmin() && this.id !== authService.user.id;
+ this.editable = authService.isAuthenticated() && (this.id == authService.user.id || authService.isGoodAdmin());
+ this.rolesEditable = authService.isGoodAdmin() && this.id != authService.user.id;
};
User.prototype.hasRole = function(role) {
diff --git a/lemon-demo-angularjs/package.json b/lemon-demo-angularjs/package.json
index 973999cb..6faefb36 100644
--- a/lemon-demo-angularjs/package.json
+++ b/lemon-demo-angularjs/package.json
@@ -2,6 +2,7 @@
"name": "angularsample",
"version": "0.0.0",
"dependencies": {
+ "natives": "^1.1.6",
"wiredep": "^2.2.2"
},
"repository": {},
diff --git a/lemon-demo-jpa/pom.xml b/lemon-demo-jpa/pom.xml
index f49a7e52..66068e70 100644
--- a/lemon-demo-jpa/pom.xml
+++ b/lemon-demo-jpa/pom.xml
@@ -14,7 +14,7 @@
com.naturalprogrammer
spring-lemon
- 1.0.0.M4
+ 1.0.2
@@ -30,10 +30,10 @@
runtime
+
- mysql
- mysql-connector-java
- runtime
+ org.postgresql
+ postgresql
@@ -50,6 +50,27 @@
org.springframework.boot
spring-boot-maven-plugin
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacacoVersion}
+
+
+
+ prepare-agent
+
+
+
+ report
+ test
+
+ report
+
+
+
+
+
diff --git a/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/MySecurityConfig.java b/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/MySecurityConfig.java
index effea0aa..e7627186 100644
--- a/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/MySecurityConfig.java
+++ b/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/MySecurityConfig.java
@@ -1,14 +1,13 @@
package com.naturalprogrammer.spring.lemondemo;
+import com.naturalprogrammer.spring.lemon.security.LemonJpaSecurityConfig;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.stereotype.Component;
-import com.naturalprogrammer.spring.lemon.security.LemonSecurityConfig;
-
@Component
-public class MySecurityConfig extends LemonSecurityConfig {
+public class MySecurityConfig extends LemonJpaSecurityConfig {
private static final Log log = LogFactory.getLog(MySecurityConfig.class);
diff --git a/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/controllers/MyController.java b/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/controllers/MyController.java
index c5d5e8e9..4f312eaf 100644
--- a/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/controllers/MyController.java
+++ b/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/controllers/MyController.java
@@ -1,13 +1,14 @@
package com.naturalprogrammer.spring.lemondemo.controllers;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-
import com.naturalprogrammer.spring.lemon.LemonController;
import com.naturalprogrammer.spring.lemondemo.entities.User;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
@RestController
-@RequestMapping("/api/core")
+@RequestMapping(MyController.BASE_URI)
public class MyController extends LemonController {
+
+ public static final String BASE_URI = "/api/core";
}
\ No newline at end of file
diff --git a/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/entities/User.java b/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/entities/User.java
index dcbdc23d..66fa23cf 100644
--- a/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/entities/User.java
+++ b/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/entities/User.java
@@ -1,43 +1,37 @@
package com.naturalprogrammer.spring.lemondemo.entities;
-import java.io.Serializable;
+import com.fasterxml.jackson.annotation.JsonView;
+import com.naturalprogrammer.spring.lemon.commons.util.UserUtils;
+import com.naturalprogrammer.spring.lemon.domain.AbstractUser;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
-
-import com.fasterxml.jackson.annotation.JsonView;
-import com.naturalprogrammer.spring.lemon.commons.util.UserUtils;
-import com.naturalprogrammer.spring.lemon.domain.AbstractUser;
+import java.io.Serializable;
@Entity
@Table(name="usr")
-public class User extends AbstractUser {
+@Getter @Setter @NoArgsConstructor
+public class User extends AbstractUser {
private static final long serialVersionUID = 2716710947175132319L;
public static final int NAME_MIN = 1;
public static final int NAME_MAX = 50;
+ @Getter @Setter @ToString
public static class Tag implements Serializable {
private static final long serialVersionUID = -2129078111926834670L;
-
private String name;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
}
- public User() {}
-
public User(String email, String password, String name) {
this.email = email;
this.password = password;
@@ -47,7 +41,7 @@ public User(String email, String password, String name) {
@JsonView(UserUtils.SignupInput.class)
@NotBlank(message = "{blank.name}", groups = {UserUtils.SignUpValidation.class, UserUtils.UpdateValidation.class})
@Size(min=NAME_MIN, max=NAME_MAX, groups = {UserUtils.SignUpValidation.class, UserUtils.UpdateValidation.class})
- @Column(nullable = false, length = NAME_MAX)
+ @Column(nullable = false, length = NAME_MAX) // Note: don't use JPA annotations on getter: https://github.com/naturalprogrammer/spring-lemon/issues/9
private String name;
@Override
@@ -57,12 +51,4 @@ public Tag toTag() {
tag.setName(name);
return tag;
}
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
}
\ No newline at end of file
diff --git a/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/services/MyService.java b/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/services/MyService.java
index be2754b0..206cd1c7 100644
--- a/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/services/MyService.java
+++ b/lemon-demo-jpa/src/main/java/com/naturalprogrammer/spring/lemondemo/services/MyService.java
@@ -1,14 +1,13 @@
package com.naturalprogrammer.spring.lemondemo.services;
-import java.util.Map;
-
-import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
-import org.springframework.stereotype.Service;
-
import com.naturalprogrammer.spring.lemon.LemonService;
import com.naturalprogrammer.spring.lemon.commons.security.UserDto;
-import com.naturalprogrammer.spring.lemon.util.LemonUtils;
+import com.naturalprogrammer.spring.lemon.commonsjpa.LecjUtils;
import com.naturalprogrammer.spring.lemondemo.entities.User;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
@Service
public class MyService extends LemonService {
@@ -21,14 +20,14 @@ public User newUser() {
}
@Override
- protected void updateUserFields(User user, User updatedUser, UserDto currentUser) {
+ protected void updateUserFields(User user, User updatedUser, UserDto currentUser) {
super.updateUserFields(user, updatedUser, currentUser);
user.setName(updatedUser.getName());
- LemonUtils.afterCommit(() -> {
- if (currentUser.getId().equals(user.getId()))
+ LecjUtils.afterCommit(() -> {
+ if (currentUser.getId().equals(user.getId().toString()))
currentUser.setTag(user.toTag());
});
}
@@ -39,8 +38,7 @@ protected User createAdminUser() {
User user = super.createAdminUser();
user.setName(ADMIN_NAME);
return user;
- }
-
+ }
@Override
public void fillAdditionalFields(String registrationId, User user, Map attributes) {
@@ -63,4 +61,10 @@ public void fillAdditionalFields(String registrationId, User user, Map form(Long jwtExpirationMillis) {
-//
-// NonceForm nonceForm = new NonceForm<>();
-// nonceForm.setNonce(NONCE);
-// nonceForm.setUserId(UNVERIFIED_USER_ID);
-// nonceForm.setExpirationMillis(jwtExpirationMillis);
-//
-// return nonceForm;
-// }
-//
-// @Before
-// public void setUp() {
-//
-// User user = userRepository.findById(UNVERIFIED_USER_ID).get();
-// user.setNonce(NONCE);
-// userRepository.save(user);
-// }
-//
-// @Test
-// public void testLoginWithNonce() throws Exception {
-//
-// String token = loginWithNonce(null);
-//
-// User user = userRepository.findById(UNVERIFIED_USER_ID).get();
-// Assert.assertNull(user.getNonce());
-//
-// ensureTokenWorks(token);
-//
-// // Retry should fail
-// mvc.perform(post("/api/core/login-with-nonce")
-// .contentType(MediaType.APPLICATION_JSON)
-// .content(LemonUtils.toJson(form(null))))
-// .andExpect(status().is(401));
-// }
-//
-// @Test
-// public void testLoginWithNonceExpiryOk() throws Exception {
-//
-// String token = loginWithNonce(1000L);
-// ensureTokenWorks(token);
-// }
-//
-// @Test
-// public void testLoginWithNonceExpiryShouldFail() throws Exception {
-//
-// String token = loginWithNonce(100L);
-// Thread.sleep(101L);
-//
-// mvc.perform(get("/api/core/context")
-// .header(LemonSecurityConfig.TOKEN_REQUEST_HEADER_NAME, token))
-// .andExpect(status().is(401));
-// }
-//
-// @Test
-// public void testLoginWithNonceInvalidData() throws Exception {
-//
-// mvc.perform(post("/api/core/login-with-nonce")
-// .contentType(MediaType.APPLICATION_JSON)
-// .content(LemonUtils.toJson(new NonceForm())))
-// .andExpect(status().is(422))
-// .andExpect(jsonPath("$.errors[*].field").value(hasSize(2)))
-// .andExpect(jsonPath("$.errors[*].field").value(hasItems(
-// "nonce.userId",
-// "nonce.nonce")));
-// }
-//
-// @Test
-// public void testLoginWithNonceUnknownUser() throws Exception {
-//
-// NonceForm form = form(null);
-// form.setUserId(99L);
-//
-// mvc.perform(post("/api/core/login-with-nonce")
-// .contentType(MediaType.APPLICATION_JSON)
-// .content(LemonUtils.toJson(form)))
-// .andExpect(status().is(404));
-// }
-//
-// @Test
-// public void testLoginWithWrongNonce() throws Exception {
-//
-// NonceForm form = form(null);
-// form.setNonce("wrong-nonce");
-//
-// mvc.perform(post("/api/core/login-with-nonce")
-// .contentType(MediaType.APPLICATION_JSON)
-// .content(LemonUtils.toJson(form)))
-// .andExpect(status().is(401));
-// }
-//
-// private String loginWithNonce(Long jwtExpirationMillis) throws JsonProcessingException, Exception {
-//
-// MvcResult result = mvc.perform(post("/api/core/login-with-nonce")
-// .contentType(MediaType.APPLICATION_JSON)
-// .content(LemonUtils.toJson(form(jwtExpirationMillis))))
-// .andExpect(status().is(200))
-// .andExpect(header().string(LemonSecurityConfig.TOKEN_RESPONSE_HEADER_NAME, containsString(".")))
-// .andExpect(jsonPath("$.id").value(UNVERIFIED_USER_ID))
-// .andReturn();
-//
-// return result.getResponse().getHeader(LemonSecurityConfig.TOKEN_RESPONSE_HEADER_NAME);
-// }
-//}
diff --git a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/RequestEmailChangeMvcTests.java b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/RequestEmailChangeMvcTests.java
index a403654f..38f7fa75 100644
--- a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/RequestEmailChangeMvcTests.java
+++ b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/RequestEmailChangeMvcTests.java
@@ -1,7 +1,16 @@
package com.naturalprogrammer.spring.lemondemo;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemondemo.entities.User;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+
import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -9,16 +18,7 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-import org.junit.Assert;
-import org.junit.Test;
-import org.springframework.http.MediaType;
-
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
-import com.naturalprogrammer.spring.lemon.util.LemonUtils;
-import com.naturalprogrammer.spring.lemondemo.entities.User;
-
-public class RequestEmailChangeMvcTests extends AbstractMvcTests {
+class RequestEmailChangeMvcTests extends AbstractMvcTests {
private static final String NEW_EMAIL = "new.email@example.com";
@@ -32,47 +32,47 @@ private User form() {
}
@Test
- public void testRequestEmailChange() throws Exception {
+ void testRequestEmailChange() throws Exception {
mvc.perform(post("/api/core/users/{id}/email-change-request", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
- .content(LemonUtils.toJson(form())))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
+ .content(LecUtils.toJson(form())))
.andExpect(status().is(204));
verify(mailSender).send(any());
User updatedUser = userRepository.findById(UNVERIFIED_USER_ID).get();
- Assert.assertEquals(NEW_EMAIL, updatedUser.getNewEmail());
- Assert.assertEquals(UNVERIFIED_USER_EMAIL, updatedUser.getEmail());
+ assertEquals(NEW_EMAIL, updatedUser.getNewEmail());
+ assertEquals(UNVERIFIED_USER_EMAIL, updatedUser.getEmail());
}
/**
* A good admin should be able to request changing email of another user.
*/
@Test
- public void testGoodAdminRequestEmailChange() throws Exception {
+ void testGoodAdminRequestEmailChange() throws Exception {
mvc.perform(post("/api/core/users/{id}/email-change-request", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(ADMIN_ID))
- .content(LemonUtils.toJson(form())))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(ADMIN_ID))
+ .content(LecUtils.toJson(form())))
.andExpect(status().is(204));
User updatedUser = userRepository.findById(UNVERIFIED_USER_ID).get();
- Assert.assertEquals(NEW_EMAIL, updatedUser.getNewEmail());
+ assertEquals(NEW_EMAIL, updatedUser.getNewEmail());
}
/**
* A request changing email of unknown user.
*/
@Test
- public void testRequestEmailChangeUnknownUser() throws Exception {
+ void testRequestEmailChangeUnknownUser() throws Exception {
mvc.perform(post("/api/core/users/99/email-change-request")
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(ADMIN_ID))
- .content(LemonUtils.toJson(form())))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(ADMIN_ID))
+ .content(LecUtils.toJson(form())))
.andExpect(status().is(404));
verify(mailSender, never()).send(any());
@@ -83,35 +83,31 @@ public void testRequestEmailChangeUnknownUser() throws Exception {
* the email id of another user
*/
@Test
- public void testNonAdminRequestEmailChangeAnotherUser() throws Exception {
+ void testNonAdminRequestEmailChangeAnotherUser() throws Exception {
mvc.perform(post("/api/core/users/{id}/email-change-request", ADMIN_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(USER_ID))
- .content(LemonUtils.toJson(form())))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(USER_ID))
+ .content(LecUtils.toJson(form())))
.andExpect(status().is(403));
verify(mailSender, never()).send(any());
User updatedUser = userRepository.findById(UNVERIFIED_USER_ID).get();
- Assert.assertNull(updatedUser.getNewEmail());
+ assertNull(updatedUser.getNewEmail());
}
/**
* A bad admin trying to change the email id
* of another user
*/
- /**
- * A non-admin should not be able to request changing
- * the email id of another user
- */
@Test
- public void testBadAdminRequestEmailChangeAnotherUser() throws Exception {
+ void testBadAdminRequestEmailChangeAnotherUser() throws Exception {
mvc.perform(post("/api/core/users/{id}/email-change-request", ADMIN_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_ADMIN_ID))
- .content(LemonUtils.toJson(form())))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_ADMIN_ID))
+ .content(LecUtils.toJson(form())))
.andExpect(status().is(403));
verify(mailSender, never()).send(any());
@@ -119,17 +115,15 @@ public void testBadAdminRequestEmailChangeAnotherUser() throws Exception {
/**
* Trying with invalid data.
- * @throws Exception
- * @throws JsonProcessingException
*/
@Test
- public void tryingWithInvalidData() throws JsonProcessingException, Exception {
+ void tryingWithInvalidData() throws JsonProcessingException, Exception {
// try with null newEmail and password
mvc.perform(post("/api/core/users/{id}/email-change-request", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
- .content(LemonUtils.toJson(new User())))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
+ .content(LecUtils.toJson(new User())))
.andExpect(status().is(422))
.andExpect(jsonPath("$.errors[*].field").value(hasSize(2)))
.andExpect(jsonPath("$.errors[*].field").value(hasItems(
@@ -140,11 +134,11 @@ public void tryingWithInvalidData() throws JsonProcessingException, Exception {
updatedUser.setPassword("");
updatedUser.setNewEmail("");
- // try with null newEmail and password
+ // try with blank newEmail and password
mvc.perform(post("/api/core/users/{id}/email-change-request", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
- .content(LemonUtils.toJson(updatedUser)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
+ .content(LecUtils.toJson(updatedUser)))
.andExpect(status().is(422))
.andExpect(jsonPath("$.errors[*].field").value(hasSize(4)))
.andExpect(jsonPath("$.errors[*].field").value(hasItems(
@@ -156,8 +150,8 @@ public void tryingWithInvalidData() throws JsonProcessingException, Exception {
updatedUser.setNewEmail("an-invalid-email");
mvc.perform(post("/api/core/users/{id}/email-change-request", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
- .content(LemonUtils.toJson(updatedUser)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
+ .content(LecUtils.toJson(updatedUser)))
.andExpect(status().is(422))
.andExpect(jsonPath("$.errors[*].field").value(hasSize(1)))
.andExpect(jsonPath("$.errors[*].field").value(hasItems("updatedUser.newEmail")));
@@ -167,8 +161,8 @@ public void tryingWithInvalidData() throws JsonProcessingException, Exception {
updatedUser.setPassword("wrong-password");
mvc.perform(post("/api/core/users/{id}/email-change-request", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
- .content(LemonUtils.toJson(updatedUser)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
+ .content(LecUtils.toJson(updatedUser)))
.andExpect(status().is(422))
.andExpect(jsonPath("$.errors[*].field").value(hasSize(1)))
.andExpect(jsonPath("$.errors[*].field").value(hasItems("updatedUser.password")));
@@ -178,8 +172,8 @@ public void tryingWithInvalidData() throws JsonProcessingException, Exception {
updatedUser.setPassword(null);
mvc.perform(post("/api/core/users/{id}/email-change-request", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
- .content(LemonUtils.toJson(updatedUser)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
+ .content(LecUtils.toJson(updatedUser)))
.andExpect(status().is(422))
.andExpect(jsonPath("$.errors[*].field").value(hasSize(1)))
.andExpect(jsonPath("$.errors[*].field").value(hasItems("updatedUser.password")));
@@ -189,8 +183,8 @@ public void tryingWithInvalidData() throws JsonProcessingException, Exception {
updatedUser.setNewEmail(ADMIN_EMAIL);;
mvc.perform(post("/api/core/users/{id}/email-change-request", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
- .content(LemonUtils.toJson(updatedUser)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
+ .content(LecUtils.toJson(updatedUser)))
.andExpect(status().is(422))
.andExpect(jsonPath("$.errors[*].field").value(hasSize(1)))
.andExpect(jsonPath("$.errors[*].field").value(hasItems("updatedUser.newEmail")));
diff --git a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/ResendVerificationMailMvcTests.java b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/ResendVerificationMailMvcTests.java
index e8b824fd..a20a32f5 100644
--- a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/ResendVerificationMailMvcTests.java
+++ b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/ResendVerificationMailMvcTests.java
@@ -1,51 +1,50 @@
package com.naturalprogrammer.spring.lemondemo;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-import org.junit.Test;
-
-import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
-
-public class ResendVerificationMailMvcTests extends AbstractMvcTests {
+class ResendVerificationMailMvcTests extends AbstractMvcTests {
@Test
- public void testResendVerificationMail() throws Exception {
+ void testResendVerificationMail() throws Exception {
mvc.perform(post("/api/core/users/{id}/resend-verification-mail", UNVERIFIED_USER_ID)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID)))
.andExpect(status().is(204));
verify(mailSender).send(any());
}
@Test
- public void testAdminResendVerificationMailOtherUser() throws Exception {
+ void testAdminResendVerificationMailOtherUser() throws Exception {
mvc.perform(post("/api/core/users/{id}/resend-verification-mail", UNVERIFIED_USER_ID)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(ADMIN_ID)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(ADMIN_ID)))
.andExpect(status().is(204));
}
@Test
- public void testBadAdminResendVerificationMailOtherUser() throws Exception {
+ void testBadAdminResendVerificationMailOtherUser() throws Exception {
mvc.perform(post("/api/core/users/{id}/resend-verification-mail", UNVERIFIED_USER_ID)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_ADMIN_ID)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_ADMIN_ID)))
.andExpect(status().is(403));
mvc.perform(post("/api/core/users/{id}/resend-verification-mail", UNVERIFIED_USER_ID)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(BLOCKED_ADMIN_ID)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(BLOCKED_ADMIN_ID)))
.andExpect(status().is(403));
verify(mailSender, never()).send(any());
}
@Test
- public void testResendVerificationMailUnauthenticated() throws Exception {
+ void testResendVerificationMailUnauthenticated() throws Exception {
mvc.perform(post("/api/core/users/{id}/resend-verification-mail", UNVERIFIED_USER_ID))
.andExpect(status().is(403));
@@ -54,30 +53,30 @@ public void testResendVerificationMailUnauthenticated() throws Exception {
}
@Test
- public void testResendVerificationMailAlreadyVerified() throws Exception {
+ void testResendVerificationMailAlreadyVerified() throws Exception {
mvc.perform(post("/api/core/users/{id}/resend-verification-mail", USER_ID)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(USER_ID)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(USER_ID)))
.andExpect(status().is(422));
verify(mailSender, never()).send(any());
}
@Test
- public void testResendVerificationMailOtherUser() throws Exception {
+ void testResendVerificationMailOtherUser() throws Exception {
mvc.perform(post("/api/core/users/{id}/resend-verification-mail", UNVERIFIED_USER_ID)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(USER_ID)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(USER_ID)))
.andExpect(status().is(403));
verify(mailSender, never()).send(any());
}
@Test
- public void testResendVerificationMailNonExistingUser() throws Exception {
+ void testResendVerificationMailNonExistingUser() throws Exception {
mvc.perform(post("/api/core/users/99/resend-verification-mail")
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(ADMIN_ID)))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(ADMIN_ID)))
.andExpect(status().is(404));
verify(mailSender, never()).send(any());
diff --git a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/ResetPasswordMvcTests.java b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/ResetPasswordMvcTests.java
index 801add24..efee8510 100644
--- a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/ResetPasswordMvcTests.java
+++ b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/ResetPasswordMvcTests.java
@@ -1,39 +1,35 @@
package com.naturalprogrammer.spring.lemondemo;
-import static org.hamcrest.Matchers.containsString;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.MediaType;
-
import com.fasterxml.jackson.core.JsonProcessingException;
import com.naturalprogrammer.spring.lemon.commons.domain.ResetPasswordForm;
-import com.naturalprogrammer.spring.lemon.commons.security.JwtService;
+import com.naturalprogrammer.spring.lemon.commons.security.GreenTokenService;
import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
-import com.naturalprogrammer.spring.lemon.util.LemonUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
-public class ResetPasswordMvcTests extends AbstractMvcTests {
+class ResetPasswordMvcTests extends AbstractMvcTests {
private String forgotPasswordCode;
@Autowired
- private JwtService jwtService;
+ private GreenTokenService greenTokenService;
- @Before
+ @BeforeEach
public void setUp() {
- forgotPasswordCode = jwtService.createToken(
- JwtService.FORGOT_PASSWORD_AUDIENCE,
+ forgotPasswordCode = greenTokenService.createToken(
+ GreenTokenService.FORGOT_PASSWORD_AUDIENCE,
ADMIN_EMAIL, 60000L);
}
@Test
- public void testResetPassword() throws Exception {
+ void testResetPassword() throws Exception {
final String NEW_PASSWORD = "newPassword!";
@@ -57,7 +53,7 @@ public void testResetPassword() throws Exception {
}
@Test
- public void testResetPasswordInvalidData() throws Exception {
+ void testResetPasswordInvalidData() throws Exception {
// Wrong code
mvc.perform(post("/api/core/reset-password")
@@ -84,6 +80,6 @@ private String form(String code, String newPassword) throws JsonProcessingExcept
form.setCode(code);
form.setNewPassword(newPassword);
- return LemonUtils.toJson(form);
+ return LecUtils.toJson(form);
}
}
diff --git a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/SignupMvcTests.java b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/SignupMvcTests.java
index f6ef38d8..7d124129 100644
--- a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/SignupMvcTests.java
+++ b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/SignupMvcTests.java
@@ -1,52 +1,64 @@
package com.naturalprogrammer.spring.lemondemo;
-import static org.hamcrest.Matchers.containsString;
-import static org.hamcrest.Matchers.hasItems;
-import static org.hamcrest.Matchers.hasSize;
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemondemo.entities.User;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.jdbc.Sql;
+
+import javax.validation.ConstraintViolationException;
+
+import static org.hamcrest.Matchers.*;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-import org.junit.Assert;
-import org.junit.Test;
-import org.springframework.http.MediaType;
-import org.springframework.test.context.jdbc.Sql;
-
-import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
-import com.naturalprogrammer.spring.lemon.util.LemonUtils;
-import com.naturalprogrammer.spring.lemondemo.entities.User;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Sql({"/test-data/initialize.sql", "/test-data/finalize.sql"})
-public class SignupMvcTests extends AbstractMvcTests {
+class SignupMvcTests extends AbstractMvcTests {
@Test
- public void testSignupWithInvalidData() throws Exception {
+ void testSignupWithInvalidData() throws Exception {
User invalidUser = new User("abc", "user1", null);
mvc.perform(post("/api/core/users")
.contentType(MediaType.APPLICATION_JSON)
- .content(LemonUtils.toJson(invalidUser)))
+ .content(LecUtils.toJson(invalidUser)))
.andExpect(status().is(422))
- .andExpect(jsonPath("$.errors[*].field").value(hasSize(4)))
- .andExpect(jsonPath("$.errors[*].field").value(hasItems(
- "user.email", "user.password", "user.name")));
+ .andExpect(jsonPath("id").isString())
+ .andExpect(jsonPath("exceptionId").value(ConstraintViolationException.class.getSimpleName()))
+ .andExpect(jsonPath("error").value("Unprocessable Entity"))
+ .andExpect(jsonPath("message").value("Validation Error"))
+ .andExpect(jsonPath("status").value(HttpStatus.UNPROCESSABLE_ENTITY.value()))
+ .andExpect(jsonPath("errors[*].field").value(hasSize(4)))
+ .andExpect(jsonPath("errors[*].field").value(hasItems(
+ "user.email", "user.password", "user.name")))
+ .andExpect(jsonPath("errors[*].code").value(hasItems(
+ "{com.naturalprogrammer.spring.invalid.email}",
+ "{blank.name}",
+ "{com.naturalprogrammer.spring.invalid.email.size}",
+ "{com.naturalprogrammer.spring.invalid.password.size}")))
+ .andExpect(jsonPath("errors[*].message").value(hasItems(
+ "Not a well formed email address",
+ "Name required",
+ "Email must be between 4 and 250 characters",
+ "Password must be between 6 and 50 characters")));
verify(mailSender, never()).send(any());
}
@Test
- public void testSignup() throws Exception {
+ void testSignup() throws Exception {
User user = new User("user.foo@example.com", "user123", "User Foo");
mvc.perform(post("/api/core/users")
.contentType(MediaType.APPLICATION_JSON)
- .content(LemonUtils.toJson(user)))
+ .content(LecUtils.toJson(user)))
.andExpect(status().is(201))
.andExpect(header().string(LecUtils.TOKEN_RESPONSE_HEADER_NAME, containsString(".")))
.andExpect(jsonPath("$.id").exists())
@@ -64,31 +76,17 @@ public void testSignup() throws Exception {
verify(mailSender).send(any());
// Ensure that password got encrypted
- Assert.assertNotEquals("user123", userRepository.findByEmail("user.foo@example.com").get().getPassword());
+ assertNotEquals("user123", userRepository.findByEmail("user.foo@example.com").get().getPassword());
}
-// @Test
-// public void testSignupLoggedIn() throws Exception {
-//
-// String adminToken = login("admin@example.com", "admin!");
-//
-// User user = new User("user1@example.com", "user123", "User 1");
-//
-// mvc.perform(post("/api/core/users")
-// .header(LemonSecurityConfig.TOKEN_REQUEST_HEADER_NAME, adminToken)
-// .contentType(MediaType.APPLICATION_JSON)
-// .content(LemonUtils.toJson(user)))
-// .andExpect(status().is(403));
-// }
-//
@Test
- public void testSignupDuplicateEmail() throws Exception {
+ void testSignupDuplicateEmail() throws Exception {
User user = new User("user@example.com", "user123", "User");
mvc.perform(post("/api/core/users")
.contentType(MediaType.APPLICATION_JSON)
- .content(LemonUtils.toJson(user)))
+ .content(LecUtils.toJson(user)))
.andExpect(status().is(422));
verify(mailSender, never()).send(any());
diff --git a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/UpdateUserMvcTests.java b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/UpdateUserMvcTests.java
index 618e66b5..69919bdf 100644
--- a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/UpdateUserMvcTests.java
+++ b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/UpdateUserMvcTests.java
@@ -1,28 +1,26 @@
package com.naturalprogrammer.spring.lemondemo;
-import static org.hamcrest.Matchers.containsString;
-import static org.hamcrest.Matchers.hasSize;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-import java.io.IOException;
-
-import org.junit.Assert;
-import org.junit.Test;
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemon.commons.util.UserUtils;
+import com.naturalprogrammer.spring.lemondemo.entities.User;
+import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.jdbc.Sql;
-import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
-import com.naturalprogrammer.spring.lemon.commons.util.UserUtils;
-import com.naturalprogrammer.spring.lemon.util.LemonUtils;
-import com.naturalprogrammer.spring.lemondemo.entities.User;
+import java.io.IOException;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Sql({"/test-data/initialize.sql", "/test-data/finalize.sql"})
-public class UpdateUserMvcTests extends AbstractMvcTests {
+class UpdateUserMvcTests extends AbstractMvcTests {
private static final String UPDATED_NAME = "Edited name";
@@ -33,22 +31,22 @@ public class UpdateUserMvcTests extends AbstractMvcTests {
@Value("classpath:/update-user/patch-update-user.json")
public void setUserPatch(Resource patch) throws IOException {
- this.userPatch = LemonUtils.toString(patch);
+ this.userPatch = LecUtils.toStr(patch);
}
@Value("classpath:/update-user/patch-admin-role.json")
public void setUserPatchAdminRole(Resource patch) throws IOException {
- this.userPatchAdminRole = LemonUtils.toString(patch);;
+ this.userPatchAdminRole = LecUtils.toStr(patch);;
}
@Value("classpath:/update-user/patch-null-name.json")
public void setUserPatchNullName(Resource patch) throws IOException {
- this.userPatchNullName = LemonUtils.toString(patch);;
+ this.userPatchNullName = LecUtils.toStr(patch);;
}
@Value("classpath:/update-user/patch-long-name.json")
public void setUserPatchLongName(Resource patch) throws IOException {
- this.userPatchLongName = LemonUtils.toString(patch);;
+ this.userPatchLongName = LecUtils.toStr(patch);;
}
/**
@@ -56,14 +54,13 @@ public void setUserPatchLongName(Resource patch) throws IOException {
* but changes in roles should be skipped.
* The name of security principal object should also
* change in the process.
- * @throws Exception
*/
@Test
- public void testUpdateSelf() throws Exception {
+ void testUpdateSelf() throws Exception {
mvc.perform(patch("/api/core/users/{id}", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
.content(userPatch))
.andExpect(status().is(200))
.andExpect(header().string(LecUtils.TOKEN_RESPONSE_HEADER_NAME, containsString(".")))
@@ -75,15 +72,15 @@ public void testUpdateSelf() throws Exception {
User user = userRepository.findById(UNVERIFIED_USER_ID).get();
// Ensure that data changed properly
- Assert.assertEquals(UNVERIFIED_USER_EMAIL, user.getEmail());
- Assert.assertEquals(1, user.getRoles().size());
- Assert.assertTrue(user.getRoles().contains(UserUtils.Role.UNVERIFIED));
- Assert.assertEquals(2L, user.getVersion().longValue());
+ assertEquals(UNVERIFIED_USER_EMAIL, user.getEmail());
+ assertEquals(1, user.getRoles().size());
+ assertTrue(user.getRoles().contains(UserUtils.Role.UNVERIFIED));
+ assertEquals(2L, user.getVersion().longValue());
// Version mismatch
mvc.perform(patch("/api/core/users/{id}", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
.content(userPatch))
.andExpect(status().is(409));
}
@@ -93,14 +90,13 @@ public void testUpdateSelf() throws Exception {
* The name of security principal object should NOT change in the process,
* and the verification code should get set/unset on addition/deletion of
* the UNVERIFIED role.
- * @throws Exception
*/
@Test
- public void testGoodAdminCanUpdateOther() throws Exception {
+ void testGoodAdminCanUpdateOther() throws Exception {
mvc.perform(patch("/api/core/users/{id}", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(ADMIN_ID))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(ADMIN_ID))
.content(userPatch))
.andExpect(status().is(200))
.andExpect(header().string(LecUtils.TOKEN_RESPONSE_HEADER_NAME, containsString(".")))
@@ -113,68 +109,65 @@ public void testGoodAdminCanUpdateOther() throws Exception {
User user = userRepository.findById(UNVERIFIED_USER_ID).get();
// Ensure that data changed properly
- Assert.assertEquals(UNVERIFIED_USER_EMAIL, user.getEmail());
- Assert.assertEquals(1, user.getRoles().size());
- Assert.assertTrue(user.getRoles().contains(UserUtils.Role.ADMIN));
+ assertEquals(UNVERIFIED_USER_EMAIL, user.getEmail());
+ assertEquals(1, user.getRoles().size());
+ assertTrue(user.getRoles().contains(UserUtils.Role.ADMIN));
}
/**
* Providing an unknown id should return 404.
*/
@Test
- public void testUpdateUnknownId() throws Exception {
+ void testUpdateUnknownId() throws Exception {
mvc.perform(patch("/api/core/users/{id}", 99)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(ADMIN_ID))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(ADMIN_ID))
.content(userPatch))
.andExpect(status().is(404));
}
/**
* A non-admin trying to update the name and roles of another user should throw exception
- * @throws Exception
*/
@Test
- public void testUpdateAnotherUser() throws Exception {
+ void testUpdateAnotherUser() throws Exception {
mvc.perform(patch("/api/core/users/{id}", ADMIN_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
.content(userPatch))
.andExpect(status().is(403));
}
/**
* A bad ADMIN trying to update the name and roles of another user should throw exception
- * @throws Exception
*/
@Test
- public void testBadAdminUpdateAnotherUser() throws Exception {
+ void testBadAdminUpdateAnotherUser() throws Exception {
mvc.perform(patch("/api/core/users/{id}", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_ADMIN_ID))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_ADMIN_ID))
.content(userPatch))
.andExpect(status().is(403));
mvc.perform(patch("/api/core/users/{id}", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(BLOCKED_ADMIN_ID))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(BLOCKED_ADMIN_ID))
.content(userPatch))
.andExpect(status().is(403));
}
/**
* A good ADMIN should not be able to change his own roles
- * @throws Exception
*/
@Test
- public void goodAdminCanNotUpdateSelfRoles() throws Exception {
+ void goodAdminCanNotUpdateSelfRoles() throws Exception {
mvc.perform(patch("/api/core/users/{id}", ADMIN_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(ADMIN_ID))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(ADMIN_ID))
.content(userPatchAdminRole))
.andExpect(status().is(200))
.andExpect(jsonPath("$.tag.name").value(UPDATED_NAME))
@@ -184,22 +177,21 @@ public void goodAdminCanNotUpdateSelfRoles() throws Exception {
/**
* Invalid name
- * @throws Exception
*/
@Test
- public void testUpdateUserInvalidNewName() throws Exception {
+ void testUpdateUserInvalidNewName() throws Exception {
// Null name
mvc.perform(patch("/api/core/users/{id}", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
.content(userPatchNullName))
.andExpect(status().is(422));
// Too long name
mvc.perform(patch("/api/core/users/{id}", UNVERIFIED_USER_ID)
.contentType(MediaType.APPLICATION_JSON)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID))
+ .header(HttpHeaders.AUTHORIZATION, tokens.get(UNVERIFIED_USER_ID))
.content(userPatchLongName))
.andExpect(status().is(422));
}
diff --git a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/VerificationMvcTests.java b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/VerificationMvcTests.java
index f360e8f4..00224391 100644
--- a/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/VerificationMvcTests.java
+++ b/lemon-demo-jpa/src/test/java/com/naturalprogrammer/spring/lemondemo/VerificationMvcTests.java
@@ -1,38 +1,37 @@
package com.naturalprogrammer.spring.lemondemo;
-import static org.hamcrest.Matchers.containsString;
-import static org.hamcrest.Matchers.hasSize;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-import org.junit.Before;
-import org.junit.Test;
+import com.naturalprogrammer.spring.lemon.commons.security.GreenTokenService;
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemon.exceptions.util.LexUtils;
+import com.naturalprogrammer.spring.lemondemo.entities.User;
+import org.aspectj.lang.annotation.Before;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
-import com.naturalprogrammer.spring.lemon.commons.security.JwtService;
-import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
-import com.naturalprogrammer.spring.lemondemo.entities.User;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.hasSize;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
-public class VerificationMvcTests extends AbstractMvcTests {
+class VerificationMvcTests extends AbstractMvcTests {
private String verificationCode;
@Autowired
- private JwtService jwtService;
+ private GreenTokenService greenTokenService;
- @Before
+ @BeforeEach
public void setUp() {
- verificationCode = jwtService.createToken(JwtService.VERIFY_AUDIENCE,
+ verificationCode = greenTokenService.createToken(GreenTokenService.VERIFY_AUDIENCE,
Long.toString(UNVERIFIED_USER_ID), 60000L,
LecUtils.mapOf("email", UNVERIFIED_USER_EMAIL));
}
@Test
- public void testEmailVerification() throws Exception {
+ void testEmailVerification() throws Exception {
mvc.perform(post("/api/core/users/{userId}/verification", UNVERIFIED_USER_ID)
.param("code", verificationCode)
@@ -52,17 +51,16 @@ public void testEmailVerification() throws Exception {
}
@Test
- public void testEmailVerificationNonExistingUser() throws Exception {
+ void testEmailVerificationNonExistingUser() throws Exception {
mvc.perform(post("/api/core/users/99/verification")
.param("code", verificationCode)
- .header("contentType", MediaType.APPLICATION_FORM_URLENCODED)
- .header(LecUtils.TOKEN_REQUEST_HEADER_NAME, tokens.get(UNVERIFIED_USER_ID)))
+ .header("contentType", MediaType.APPLICATION_FORM_URLENCODED))
.andExpect(status().is(404));
}
@Test
- public void testEmailVerificationWrongToken() throws Exception {
+ void testEmailVerificationWrongToken() throws Exception {
// null token
mvc.perform(post("/api/core/users/{userId}/verification", UNVERIFIED_USER_ID)
@@ -76,16 +74,16 @@ public void testEmailVerificationWrongToken() throws Exception {
.andExpect(status().is(401));
// Wrong audience
- String token = jwtService.createToken("wrong-audience",
+ String token = greenTokenService.createToken("wrong-audience",
Long.toString(UNVERIFIED_USER_ID), 60000L,
LecUtils.mapOf("email", UNVERIFIED_USER_EMAIL));
mvc.perform(post("/api/core/users/{userId}/verification", UNVERIFIED_USER_ID)
.param("code", token)
.header("contentType", MediaType.APPLICATION_FORM_URLENCODED))
- .andExpect(status().is(403));
+ .andExpect(status().is(401));
// Wrong email
- token = jwtService.createToken(JwtService.VERIFY_AUDIENCE,
+ token = greenTokenService.createToken(GreenTokenService.VERIFY_AUDIENCE,
Long.toString(UNVERIFIED_USER_ID), 60000L,
LecUtils.mapOf("email", "wrong.email@example.com"));
mvc.perform(post("/api/core/users/{userId}/verification", UNVERIFIED_USER_ID)
@@ -94,28 +92,27 @@ public void testEmailVerificationWrongToken() throws Exception {
.andExpect(status().is(403));
// expired token
- token = jwtService.createToken(JwtService.VERIFY_AUDIENCE,
+ token = greenTokenService.createToken(GreenTokenService.VERIFY_AUDIENCE,
Long.toString(UNVERIFIED_USER_ID), 1L,
LecUtils.mapOf("email", UNVERIFIED_USER_EMAIL));
// Thread.sleep(1001L);
mvc.perform(post("/api/core/users/{userId}/verification", UNVERIFIED_USER_ID)
.param("code", token)
.header("contentType", MediaType.APPLICATION_FORM_URLENCODED))
- .andExpect(status().is(403));
+ .andExpect(status().is(401));
}
@Test
- public void testEmailVerificationAfterCredentialsUpdate() throws Exception {
+ void testEmailVerificationAfterCredentialsUpdate() throws Exception {
// Credentials updated after the verification token is issued
- Thread.sleep(1L);
- User user = userRepository.findById(UNVERIFIED_USER_ID).get();
- user.setCredentialsUpdatedMillis(System.currentTimeMillis());
+ User user = userRepository.findById(UNVERIFIED_USER_ID).orElseThrow(LexUtils.notFoundSupplier());
+ user.setCredentialsUpdatedMillis(System.currentTimeMillis() + 1);
userRepository.save(user);
mvc.perform(post("/api/core/users/{userId}/verification", UNVERIFIED_USER_ID)
.param("code", verificationCode)
.header("contentType", MediaType.APPLICATION_FORM_URLENCODED))
- .andExpect(status().is(403));
+ .andExpect(status().is(401));
}
}
diff --git a/lemon-demo-jpa/src/test/resources/postman/Spring Lemon 1.0.postman_collection.json b/lemon-demo-jpa/src/test/resources/postman/Spring Lemon 1.0.postman_collection.json
index 1b6ef2e9..ec088f47 100644
--- a/lemon-demo-jpa/src/test/resources/postman/Spring Lemon 1.0.postman_collection.json
+++ b/lemon-demo-jpa/src/test/resources/postman/Spring Lemon 1.0.postman_collection.json
@@ -46,7 +46,7 @@
},
"response": [
{
- "id": "dc13c098-4ab6-4708-9a22-4c6647aa1698",
+ "id": "94e97d70-c5b8-46d1-9a0a-fdd64c888f71",
"name": "Ping",
"originalRequest": {
"method": "GET",
@@ -205,11 +205,13 @@
]
},
"url": {
- "raw": "{{lemonDemoUrl}}/login",
+ "raw": "{{lemonDemoUrl}}/api/core/login",
"host": [
"{{lemonDemoUrl}}"
],
"path": [
+ "api",
+ "core",
"login"
]
},
@@ -217,7 +219,7 @@
},
"response": [
{
- "id": "e6eefb5e-b4a3-4b42-b9ad-2d2e6dbfb48d",
+ "id": "fd19ca94-a61e-4af6-8831-97d2ed9a07b7",
"name": "Login",
"originalRequest": {
"method": "POST",
@@ -252,11 +254,13 @@
]
},
"url": {
- "raw": "{{lemonDemoUrl}}/login",
+ "raw": "{{lemonDemoUrl}}/api/core/login",
"host": [
"{{lemonDemoUrl}}"
],
"path": [
+ "api",
+ "core",
"login"
]
}
@@ -330,7 +334,7 @@
"body": "{\"id\":9,\"username\":\"admin@example.com\",\"roles\":[\"ADMIN\"],\"tag\":{\"name\":\"Administrator\"},\"unverified\":false,\"blocked\":false,\"admin\":true,\"goodUser\":true,\"goodAdmin\":true}"
},
{
- "id": "e3a0b42d-4ad2-46bd-aa6e-ef47d47c60c2",
+ "id": "5badab55-018e-4fd7-baf4-31c9f6541643",
"name": "Login with wrong credentials",
"originalRequest": {
"method": "POST",
@@ -365,11 +369,13 @@
]
},
"url": {
- "raw": "{{lemonDemoUrl}}/login",
+ "raw": "{{lemonDemoUrl}}/api/core/login",
"host": [
"{{lemonDemoUrl}}"
],
"path": [
+ "api",
+ "core",
"login"
]
}
@@ -486,7 +492,7 @@
},
"response": [
{
- "id": "123567f0-1a53-4fe1-b75d-3775da8559e1",
+ "id": "be379d95-60bb-4066-af3c-1303bc1f879f",
"name": "Get context with Authorization header",
"originalRequest": {
"method": "GET",
@@ -618,7 +624,7 @@
"body": "{\"context\":{\"reCaptchaSiteKey\":\"6LdwxRcUAAAAABkhOGWQXhl9FsR27D5YUJRuGzx0\",\"shared\":{\"fooBar\":\"123...\"}},\"user\":{\"id\":2,\"username\":\"skpatel20@gmail.com\",\"roles\":[\"UNVERIFIED\"],\"tag\":{\"name\":\"Sanjay Patel\"},\"unverified\":true,\"blocked\":false,\"admin\":false,\"goodUser\":false,\"goodAdmin\":false}}"
},
{
- "id": "6f023dfc-2374-4155-86b7-236b150c427e",
+ "id": "4b46a9e7-8fc7-4c28-aa3d-f8da31ac72af",
"name": "Get context using a wrong Authorization token",
"originalRequest": {
"method": "GET",
@@ -744,7 +750,7 @@
"body": "{\"timestamp\":\"2018-03-03T06:21:34.748+0000\",\"status\":401,\"error\":\"Unauthorized\",\"message\":\"Authentication Failed: Invalid JWT serialization: Missing dot delimiter(s)\",\"path\":\"/api/core/context\"}"
},
{
- "id": "761ee1eb-aaa5-48ae-ac7b-d91a666f7ae1",
+ "id": "90c0d043-46b7-4b23-9dca-2d12f0278ab4",
"name": "Get context without Authorization header",
"originalRequest": {
"method": "GET",
@@ -975,7 +981,7 @@
},
"response": [
{
- "id": "e7d318ce-ae30-4120-a3b2-4207ffcf6661",
+ "id": "c76cf940-d63b-4692-8fa5-bf1eb6a808d2",
"name": "Sign up with invalid data",
"originalRequest": {
"method": "POST",
@@ -1100,7 +1106,7 @@
"body": "{\"exception\":\"ConstraintViolationException\",\"error\":\"Unprocessable Entity\",\"message\":\"Validation Error\",\"status\":422,\"errors\":[{\"field\":\"user.email\",\"code\":\"{com.naturalprogrammer.spring.invalid.email}\",\"message\":\"Not a well formed email address\"},{\"field\":\"user.password\",\"code\":\"{com.naturalprogrammer.spring.invalid.password.size}\",\"message\":\"Password must be between 6 and 50 characters\"},{\"field\":\"user.name\",\"code\":\"{blank.name}\",\"message\":\"Name required\"},{\"field\":\"user.email\",\"code\":\"{com.naturalprogrammer.spring.invalid.email.size}\",\"message\":\"Email must be between 4 and 250 characters\"}]}"
},
{
- "id": "ee65ea2e-743a-4135-bbab-fad97ce3c06f",
+ "id": "23eba689-6667-49f7-b1ef-501148a40922",
"name": "Sign up",
"originalRequest": {
"method": "POST",
@@ -1275,7 +1281,7 @@
},
"response": [
{
- "id": "7b70613c-b89e-45e4-b641-4ccfbde839d8",
+ "id": "bf12db2b-2585-4ca2-a709-65604a2398d8",
"name": "Resend verification mail",
"originalRequest": {
"method": "POST",
@@ -1434,7 +1440,7 @@
},
"response": [
{
- "id": "79277fc4-5434-4863-99b7-cf4529a4cbcf",
+ "id": "faaee1e5-9286-4611-889e-260834a7205b",
"name": "Verify User",
"originalRequest": {
"method": "POST",
@@ -1599,7 +1605,7 @@
},
"response": [
{
- "id": "b7913835-3286-4c8f-b2c8-56f8432bf971",
+ "id": "dfa67f50-9db1-4801-94dd-b7bba429f4c9",
"name": "Forgot password",
"originalRequest": {
"method": "POST",
@@ -1719,25 +1725,12 @@
"header": [
{
"key": "Content-Type",
- "value": "application/x-www-form-urlencoded"
+ "value": "application/json"
}
],
"body": {
- "mode": "urlencoded",
- "urlencoded": [
- {
- "key": "newPassword",
- "value": "a-new-password",
- "description": "",
- "type": "text"
- },
- {
- "key": "code",
- "value": "eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..HoDu5pdYAx8fS7p9PKCl5g.jRNA1Vu0ZydW9G4bDd6f7GirpNahXtytXke9i7M5xmrsTqUvCitotGwOm3NwhmyIUcBeRTS53lx1SCxDa8cuv9McD2v2exRfKhnNu1i7cG1E10Cmb4m9nuDuAP2LviXpUOLJyYUFRGrxQOrCwwkMNQ.iN6xYjU1Xe-w9O4VDKDEOA",
- "description": "",
- "type": "text"
- }
- ]
+ "mode": "raw",
+ "raw": "{\n\t\"newPassword\": \"a-new-password\",\n\t\"code\": \"eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..HoDu5pdYAx8fS7p9PKCl5g.jRNA1Vu0ZydW9G4bDd6f7GirpNahXtytXke9i7M5xmrsTqUvCitotGwOm3NwhmyIUcBeRTS53lx1SCxDa8cuv9McD2v2exRfKhnNu1i7cG1E10Cmb4m9nuDuAP2LviXpUOLJyYUFRGrxQOrCwwkMNQ.iN6xYjU1Xe-w9O4VDKDEOA\"\n}"
},
"url": {
"raw": "{{lemonDemoUrl}}/api/core/reset-password",
@@ -1754,35 +1747,19 @@
},
"response": [
{
- "id": "bbc954f4-d67a-421c-9045-58cf125ecd31",
+ "id": "e34d5413-6cf8-40d5-8d17-1dbf7bde7af5",
"name": "Reset password",
"originalRequest": {
"method": "POST",
"header": [
{
"key": "Content-Type",
- "type": "text",
- "name": "Content-Type",
- "value": "application/x-www-form-urlencoded",
- "disabled": false
+ "value": "application/json"
}
],
"body": {
- "mode": "urlencoded",
- "urlencoded": [
- {
- "key": "newPassword",
- "value": "a-new-password",
- "description": "",
- "type": "text"
- },
- {
- "key": "code",
- "value": "eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..HoDu5pdYAx8fS7p9PKCl5g.jRNA1Vu0ZydW9G4bDd6f7GirpNahXtytXke9i7M5xmrsTqUvCitotGwOm3NwhmyIUcBeRTS53lx1SCxDa8cuv9McD2v2exRfKhnNu1i7cG1E10Cmb4m9nuDuAP2LviXpUOLJyYUFRGrxQOrCwwkMNQ.iN6xYjU1Xe-w9O4VDKDEOA",
- "description": "",
- "type": "text"
- }
- ]
+ "mode": "raw",
+ "raw": "{\n\t\"newPassword\": \"a-new-password\",\n\t\"code\": \"eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..HoDu5pdYAx8fS7p9PKCl5g.jRNA1Vu0ZydW9G4bDd6f7GirpNahXtytXke9i7M5xmrsTqUvCitotGwOm3NwhmyIUcBeRTS53lx1SCxDa8cuv9McD2v2exRfKhnNu1i7cG1E10Cmb4m9nuDuAP2LviXpUOLJyYUFRGrxQOrCwwkMNQ.iN6xYjU1Xe-w9O4VDKDEOA\"\n}"
},
"url": {
"raw": "{{lemonDemoUrl}}/api/core/reset-password",
@@ -1929,7 +1906,7 @@
},
"response": [
{
- "id": "d1ccdda8-bf5d-446a-a468-e1fbc607e26c",
+ "id": "a5278a35-86da-4ead-b2d3-9860c1861687",
"name": "Fetch user by email - Others",
"originalRequest": {
"method": "POST",
@@ -2042,7 +2019,7 @@
"body": "{\"id\":11,\"version\":2,\"roles\":[],\"name\":\"Sample User\",\"new\":false}"
},
{
- "id": "5595f812-2a4f-4360-bf32-f1813a22a6f3",
+ "id": "c45a4fbc-9a0b-4397-8a0a-ce8d363efe99",
"name": "Fetch user by email - Self or Admin",
"originalRequest": {
"method": "POST",
@@ -2205,7 +2182,7 @@
},
"response": [
{
- "id": "2448a1cc-627d-4d23-a10f-fa4f6d32efc7",
+ "id": "2dce2342-b2e3-4269-a343-7727addd2e67",
"name": "Fetch user by ID - Self or Admin",
"originalRequest": {
"method": "GET",
@@ -2331,7 +2308,7 @@
"body": "{\"id\":2,\"version\":2,\"email\":\"skpatel20+lemon1842637@example.com\",\"roles\":[],\"name\":\"Sample User\",\"new\":false}"
},
{
- "id": "7288f472-5f1e-437a-be18-bd3e0e11d789",
+ "id": "faf6cfba-2759-4a2d-b983-2e6a7a442260",
"name": "Fetch user by ID - Others",
"originalRequest": {
"method": "GET",
@@ -2502,7 +2479,7 @@
},
"response": [
{
- "id": "9c1fc11b-c7e2-4406-9db2-07325bdd6da2",
+ "id": "380c4cbe-efd5-4bdc-9c2d-54af1ff78f54",
"name": "Update user",
"originalRequest": {
"method": "PATCH",
@@ -2689,7 +2666,7 @@
},
"response": [
{
- "id": "81590aa2-a281-4755-bf43-568fc79f82b1",
+ "id": "2f63c1e3-be33-47ca-8275-7efd188c19d3",
"name": "Change password",
"originalRequest": {
"method": "POST",
@@ -2871,7 +2848,7 @@
},
"response": [
{
- "id": "f7320de8-3bd4-45ac-851a-9b4db1870fe5",
+ "id": "290c8a5c-a3fd-4a5b-a6ae-4b76c894494c",
"name": "Requesting for changing email",
"originalRequest": {
"method": "POST",
@@ -3052,7 +3029,7 @@
},
"response": [
{
- "id": "09ead591-4e70-4726-b240-f29b5e0f16af",
+ "id": "e26ba30b-8978-4c97-8709-1972ca5a56a7",
"name": "Change email",
"originalRequest": {
"method": "POST",
@@ -3236,7 +3213,7 @@
},
"response": [
{
- "id": "af5ab736-94b8-4ef8-9f43-c240d1331df1",
+ "id": "8ff37fe9-e010-4add-9f79-b7298de2d943",
"name": "Fetch new token",
"originalRequest": {
"method": "POST",
diff --git a/lemon-demo-reactive/pom.xml b/lemon-demo-reactive/pom.xml
index a1b52aab..a310142b 100644
--- a/lemon-demo-reactive/pom.xml
+++ b/lemon-demo-reactive/pom.xml
@@ -13,7 +13,7 @@
com.naturalprogrammer
spring-lemon
- 1.0.0.M4
+ 1.0.2
@@ -24,6 +24,12 @@
${project.version}
+
+ io.projectreactor
+ reactor-test
+ test
+
+
diff --git a/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/LemonDemoReactiveApplication.java b/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/LemonDemoReactiveApplication.java
index 25d67a53..044543a2 100644
--- a/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/LemonDemoReactiveApplication.java
+++ b/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/LemonDemoReactiveApplication.java
@@ -2,26 +2,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.context.annotation.Bean;
-
-import com.fasterxml.jackson.databind.module.SimpleModule;
-import com.naturalprogrammer.spring.lemondemo.domain.User;
-import com.naturalprogrammer.spring.lemonreactive.domain.AbstractMongoUser;
@SpringBootApplication
public class LemonDemoReactiveApplication {
public static void main(String[] args) {
SpringApplication.run(LemonDemoReactiveApplication.class, args);
- }
-
- @Bean
- public SimpleModule objectIdModule() {
-
- SimpleModule module = new SimpleModule();
- module.setMixInAnnotation(User.class, AbstractMongoUser.class);
- //module.addSerializer(ObjectId.class, new ToStringSerializer());
-
- return module;
- }
+ }
}
diff --git a/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/controllers/MyController.java b/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/controllers/MyController.java
index 3aae7ac0..a9914e42 100644
--- a/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/controllers/MyController.java
+++ b/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/controllers/MyController.java
@@ -1,27 +1,27 @@
package com.naturalprogrammer.spring.lemondemo.controllers;
-import org.bson.types.ObjectId;
-import org.springframework.http.server.reactive.ServerHttpResponse;
-import org.springframework.validation.annotation.Validated;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-
import com.fasterxml.jackson.annotation.JsonView;
import com.naturalprogrammer.spring.lemon.commons.security.UserDto;
import com.naturalprogrammer.spring.lemon.commons.util.UserUtils;
import com.naturalprogrammer.spring.lemon.commons.util.UserUtils.SignUpValidation;
import com.naturalprogrammer.spring.lemondemo.domain.User;
import com.naturalprogrammer.spring.lemonreactive.LemonReactiveController;
-
+import org.bson.types.ObjectId;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
-@RequestMapping("/api/core")
+@RequestMapping(MyController.BASE_URI)
public class MyController extends LemonReactiveController {
+ public static final String BASE_URI = "/api/core";
+
@Override
- public Mono> signup(
+ public Mono signup(
@RequestBody @JsonView(UserUtils.SignupInput.class)
@Validated(SignUpValidation.class) Mono user,
ServerHttpResponse response) {
diff --git a/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/domain/User.java b/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/domain/User.java
index a613e63a..b12dfd8f 100644
--- a/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/domain/User.java
+++ b/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/domain/User.java
@@ -1,23 +1,20 @@
package com.naturalprogrammer.spring.lemondemo.domain;
-import java.io.Serializable;
-
-import javax.validation.constraints.NotBlank;
-import javax.validation.constraints.Size;
-
-import org.bson.types.ObjectId;
-import org.springframework.data.annotation.TypeAlias;
-import org.springframework.data.mongodb.core.mapping.Document;
-
import com.fasterxml.jackson.annotation.JsonView;
import com.naturalprogrammer.spring.lemon.commons.util.UserUtils;
import com.naturalprogrammer.spring.lemonreactive.domain.AbstractMongoUser;
-
import lombok.Getter;
-import lombok.NoArgsConstructor;
import lombok.Setter;
+import lombok.ToString;
+import org.bson.types.ObjectId;
+import org.springframework.data.annotation.TypeAlias;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
+import java.io.Serializable;
-@Getter @Setter @NoArgsConstructor
+@Getter @Setter
@TypeAlias("User")
@Document(collection = "usr")
public class User extends AbstractMongoUser {
@@ -25,25 +22,11 @@ public class User extends AbstractMongoUser {
public static final int NAME_MIN = 1;
public static final int NAME_MAX = 50;
+ @Getter @Setter @ToString
public static class Tag implements Serializable {
private static final long serialVersionUID = -2129078111926834670L;
-
private String name;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
- }
-
- public User(String email, String password, String name) {
- this.email = email;
- this.password = password;
- this.name = name;
}
@JsonView(UserUtils.SignupInput.class)
@@ -58,12 +41,4 @@ public Tag toTag() {
tag.setName(name);
return tag;
}
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
}
diff --git a/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/repositories/UserRepository.java b/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/repositories/UserRepository.java
index ff841fc9..fcea2d33 100644
--- a/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/repositories/UserRepository.java
+++ b/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/repositories/UserRepository.java
@@ -1,8 +1,8 @@
package com.naturalprogrammer.spring.lemondemo.repositories;
-import org.bson.types.ObjectId;
import com.naturalprogrammer.spring.lemondemo.domain.User;
import com.naturalprogrammer.spring.lemonreactive.domain.AbstractMongoUserRepository;
+import org.bson.types.ObjectId;
public interface UserRepository extends AbstractMongoUserRepository {
diff --git a/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/services/MyService.java b/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/services/MyService.java
index faf2d3f8..d61c2a3b 100644
--- a/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/services/MyService.java
+++ b/lemon-demo-reactive/src/main/java/com/naturalprogrammer/spring/lemondemo/services/MyService.java
@@ -1,11 +1,13 @@
package com.naturalprogrammer.spring.lemondemo.services;
-import org.bson.types.ObjectId;
-import org.springframework.stereotype.Service;
-
import com.naturalprogrammer.spring.lemon.commons.security.UserDto;
import com.naturalprogrammer.spring.lemondemo.domain.User;
import com.naturalprogrammer.spring.lemonreactive.LemonReactiveService;
+import org.bson.types.ObjectId;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
@Service
public class MyService extends LemonReactiveService {
@@ -26,9 +28,36 @@ protected User createAdminUser() {
}
@Override
- protected void updateUserFields(User user, User updatedUser, UserDto currentUser) {
+ protected void updateUserFields(User user, User updatedUser, UserDto currentUser) {
super.updateUserFields(user, updatedUser, currentUser);
user.setName(updatedUser.getName());
}
+
+ @Override
+ public void fillAdditionalFields(String registrationId, User user, Map attributes) {
+
+ String nameKey;
+
+ switch (registrationId) {
+
+ case "facebook":
+ nameKey = StandardClaimNames.NAME;
+ break;
+
+ case "google":
+ nameKey = StandardClaimNames.NAME;
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Fetching name from " + registrationId + " login not supprrted");
+ }
+
+ user.setName((String) attributes.get(nameKey));
+ }
+
+ @Override
+ protected ObjectId toId(String id) {
+ return new ObjectId(id);
+ }
}
diff --git a/lemon-demo-reactive/src/main/resources/ValidationMessages.properties b/lemon-demo-reactive/src/main/resources/ValidationMessages.properties
index 9145571f..533b725f 100644
--- a/lemon-demo-reactive/src/main/resources/ValidationMessages.properties
+++ b/lemon-demo-reactive/src/main/resources/ValidationMessages.properties
@@ -1,3 +1,7 @@
+#
+## Spring Lemon Bean Validation messages
+#
+
com.naturalprogrammer.spring.blank.email: Email needed
com.naturalprogrammer.spring.invalid.email: Not a well formed email address
com.naturalprogrammer.spring.invalid.email.size: Email must be between {min} and {max} characters
@@ -9,4 +13,8 @@ com.naturalprogrammer.spring.invalid.password.size: Password must be between {mi
com.naturalprogrammer.spring.different.passwords: Passwords do not match
com.naturalprogrammer.spring.blank.password: Password needed
+#
+## My Bean Validation Messages
+#
+
blank.name: Name required
diff --git a/lemon-demo-reactive/src/main/resources/config/application-dev.yml b/lemon-demo-reactive/src/main/resources/config/application-dev.yml
index 2cd5ec47..78ff07d2 100644
--- a/lemon-demo-reactive/src/main/resources/config/application-dev.yml
+++ b/lemon-demo-reactive/src/main/resources/config/application-dev.yml
@@ -4,19 +4,20 @@ spring:
data:
mongodb:
database: lemon
+
security:
oauth2:
client:
provider:
facebook:
- user-info-uri: https://graph.facebook.com/me?fields=email,name,verified
+ user-info-uri: https://graph.facebook.com/me?fields=email,name
registration:
google:
client-id: 1011974249454-6gq0hr01gqh3cndoqnss5r69tkk2nd84.apps.googleusercontent.com
client-secret: saDA6Cj60wipncFM-hzBD-C6
facebook:
- client-id: 1234020186718741
- client-secret: 0c0abaf685a83e879e8e48b1167c96ab
+ client-id: 548349525905412
+ client-secret: 15a20c560c4c780dabdc0e637c02087a
logging:
level:
diff --git a/lemon-demo-reactive/src/main/resources/config/application.yml b/lemon-demo-reactive/src/main/resources/config/application.yml
index d20bb70f..0737450c 100644
--- a/lemon-demo-reactive/src/main/resources/config/application.yml
+++ b/lemon-demo-reactive/src/main/resources/config/application.yml
@@ -13,6 +13,9 @@ spring:
deserialization:
accept-single-value-as-array: true
+ # https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.1-Release-Notes#bean-overriding
+ main.allow-bean-definition-overriding: true
+
# Spring Lemon related properties
lemon:
diff --git a/lemon-demo-reactive/src/main/resources/messages.properties b/lemon-demo-reactive/src/main/resources/messages.properties
index b05037fd..4be0d170 100644
--- a/lemon-demo-reactive/src/main/resources/messages.properties
+++ b/lemon-demo-reactive/src/main/resources/messages.properties
@@ -1,3 +1,7 @@
+#
+## Spring Lemon Normal Messages
+#
+
com.naturalprogrammer.spring.validationError: Validation Error
com.naturalprogrammer.spring.verifySubject: Please verify your email id
@@ -34,3 +38,10 @@ com.naturalprogrammer.spring.expiredToken: Expired token
com.naturalprogrammer.spring.notGoodAdminOrSameUser: Only a good Admin or same user is permitted for this operation
com.naturalprogrammer.spring.blank: Please provide {0}
+
+com.naturalprogrammer.spring.userClaimAbsent: User claim absent in the authorization token
+com.naturalprogrammer.spring.fullTokenNotAllowed: Full authorization tokens aren't allowed here
+
+#
+## My Normal Messages
+#
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/AbstractTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/AbstractTests.java
new file mode 100644
index 00000000..f6d245d2
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/AbstractTests.java
@@ -0,0 +1,51 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemon.commons.mail.MailSender;
+import com.naturalprogrammer.spring.lemondemo.dto.TestErrorResponse;
+import com.naturalprogrammer.spring.lemondemo.dto.TestLemonFieldError;
+import org.junit.jupiter.api.BeforeEach;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+
+import java.util.Arrays;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest({
+ "logging.level.com.naturalprogrammer=ERROR", // logging.level.root=ERROR does not work: https://stackoverflow.com/questions/49048298/springboottest-not-overriding-logging-level
+ "logging.level.org.springframework=ERROR",
+ "lemon.recaptcha.sitekey=",
+ "spring.data.mongodb.database=lemontest"
+})
+public abstract class AbstractTests {
+
+ @Autowired
+ protected ReactiveMongoTemplate mongoTemplate;
+
+ @Autowired
+ protected MyTestUtils testUtils;
+
+ @MockBean
+ protected MailSender> mailSender;
+
+ @BeforeEach
+ public void initialize() {
+
+ testUtils.initDatabase();
+ }
+
+ protected void assertErrors(EntityExchangeResult errorResponseResult, String... fields) {
+
+ TestErrorResponse response = errorResponseResult.getResponseBody();
+ assertEquals(fields.length, response.getErrors().size());
+
+ assertTrue(response.getErrors().stream()
+ .map(TestLemonFieldError::getField).collect(Collectors.toSet())
+ .containsAll(Arrays.asList(fields)));
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/BasicTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/BasicTests.java
new file mode 100644
index 00000000..fc934cd0
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/BasicTests.java
@@ -0,0 +1,46 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import org.junit.jupiter.api.Test;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+
+class BasicTests extends AbstractTests {
+
+ @Test
+ void testPing() throws Exception {
+
+ CLIENT.get()
+ .uri(BASE_URI + "/ping")
+ .exchange()
+ .expectStatus()
+ .isNoContent();
+ }
+
+ @Test
+ void testGetContextLoggedIn() throws Exception {
+
+ testUtils.contextResponse(TOKENS.get(ADMIN_ID))
+ .expectHeader().exists(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody()
+ .jsonPath("$.context.reCaptchaSiteKey").exists()
+ .jsonPath("$.user.id").isEqualTo(ADMIN_ID.toString())
+ .jsonPath("$.user.roles[0]").isEqualTo("ADMIN")
+ .jsonPath("$.user.password").doesNotExist();
+ }
+
+ @Test
+ void testGetContextWithoutLoggedIn() throws Exception {
+
+ CLIENT.get()
+ .uri(BASE_URI + "/context")
+ .exchange()
+ .expectStatus().isOk()
+ .expectHeader().doesNotExist(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody()
+ .jsonPath("$.context.reCaptchaSiteKey").exists()
+ .jsonPath("$.user").doesNotExist();
+ }
+
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ChangeEmailTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ChangeEmailTests.java
new file mode 100644
index 00000000..07dae7eb
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ChangeEmailTests.java
@@ -0,0 +1,156 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemon.commons.security.GreenTokenService;
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemondemo.domain.User;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.springframework.web.reactive.function.BodyInserters.fromFormData;
+
+public class ChangeEmailTests extends AbstractTests {
+
+ private static final String NEW_EMAIL = "new.email@example.com";
+
+ private String changeEmailCode;
+
+ @Autowired
+ private GreenTokenService greenTokenService;
+
+ @BeforeEach
+ public void setUp() {
+
+ User user = mongoTemplate.findById(UNVERIFIED_USER_ID, User.class).block();
+ assert user != null;
+ user.setNewEmail(NEW_EMAIL);
+ mongoTemplate.save(user).block();
+
+ changeEmailCode = greenTokenService.createToken(
+ GreenTokenService.CHANGE_EMAIL_AUDIENCE,
+ UNVERIFIED_USER_ID.toString(), 60000L,
+ LecUtils.mapOf("newEmail", NEW_EMAIL));
+ }
+
+ @Test
+ void testChangeEmail() throws Exception {
+
+ changeEmailResponse(changeEmailCode)
+ .expectStatus().isOk()
+ .expectHeader().valueMatches(LecUtils.TOKEN_RESPONSE_HEADER_NAME, ".*\\..*")
+ .expectBody().jsonPath("$.id").isEqualTo(UNVERIFIED_USER_ID.toString());
+
+ User updatedUser = mongoTemplate.findById(UNVERIFIED_USER_ID, User.class).block();
+ assertNull(updatedUser.getNewEmail());
+ assertEquals(NEW_EMAIL, updatedUser.getEmail());
+
+ changeEmailResponse(changeEmailCode)
+ .expectStatus().isUnauthorized();
+ }
+
+ /**
+ * Providing a wrong changeEmailCode shouldn't work.
+ */
+ @Test
+ void testChangeEmailWrongCode() throws Exception {
+
+ // Blank token
+ changeEmailResponse("")
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+
+ // Wrong audience
+ String code = greenTokenService.createToken(
+ "", // blank audience
+ UNVERIFIED_USER_ID.toString(), 60000L,
+ LecUtils.mapOf("newEmail", NEW_EMAIL));
+
+ changeEmailResponse(code)
+ .expectStatus().isUnauthorized();
+
+ // Wrong userId subject
+ code = greenTokenService.createToken(
+ GreenTokenService.CHANGE_EMAIL_AUDIENCE,
+ ADMIN_ID.toString(), 60000L,
+ LecUtils.mapOf("newEmail", NEW_EMAIL));
+
+ changeEmailResponse(code)
+ .expectStatus().isForbidden();
+
+ // Wrong new email
+ code = greenTokenService.createToken(
+ GreenTokenService.CHANGE_EMAIL_AUDIENCE,
+ UNVERIFIED_USER_ID.toString(), 60000L,
+ LecUtils.mapOf("newEmail", "wrong.new.email@example.com"));
+
+ changeEmailResponse(code)
+ .expectStatus().isForbidden();
+ }
+
+ /**
+ * Providing a wrong changeEmailCode shouldn't work.
+ */
+ @Test
+ public void testChangeEmailObsoleteCode() throws Exception {
+
+ // credentials updated after the request for email change was made
+ User user = mongoTemplate.findById(UNVERIFIED_USER_ID, User.class).block();
+ user.setCredentialsUpdatedMillis(System.currentTimeMillis() + 1);
+ mongoTemplate.save(user).block();
+
+ // A new auth token is needed, because old one would be obsolete!
+ String authToken = testUtils.login(UNVERIFIED_USER_EMAIL, USER_PASSWORD);
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/email", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, authToken)
+ .body(fromFormData("code", changeEmailCode))
+ .exchange()
+ .expectStatus().isUnauthorized();
+ }
+
+ /**
+ * Trying without having requested first.
+ * @throws Exception
+ */
+ @Test
+ void testChangeEmailWithoutAnyRequest() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/email", USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(USER_ID))
+ .body(fromFormData("code", changeEmailCode))
+ .exchange()
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+ }
+
+ /**
+ * Trying after some user registers the newEmail, leaving it non unique.
+ * @throws Exception
+ */
+ @Test
+ public void testChangeEmailNonUniqueEmail() throws Exception {
+
+ // Some other user changed to the same email
+ User user = mongoTemplate.findById(ADMIN_ID, User.class).block();
+ user.setEmail(NEW_EMAIL);
+ mongoTemplate.save(user).block();
+
+ // Blank token
+ changeEmailResponse(changeEmailCode)
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+ }
+
+ private ResponseSpec changeEmailResponse(String code) {
+
+ return CLIENT.post().uri(BASE_URI + "/users/{id}/email", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(UNVERIFIED_USER_ID))
+ .body(fromFormData("code", code))
+ .exchange();
+ }
+
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ChangePasswordTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ChangePasswordTests.java
new file mode 100644
index 00000000..a7c0d04c
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ChangePasswordTests.java
@@ -0,0 +1,192 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemon.commons.domain.ChangePasswordForm;
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemondemo.dto.TestErrorResponse;
+import org.bson.types.ObjectId;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import reactor.core.publisher.Mono;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+
+class ChangePasswordTests extends AbstractTests {
+
+ private static final String NEW_PASSWORD = "a-new-password";
+
+ private ChangePasswordForm changePasswordForm(String oldPassword) {
+
+ ChangePasswordForm form = new ChangePasswordForm();
+ form.setOldPassword(oldPassword);
+ form.setPassword(NEW_PASSWORD);
+ form.setRetypePassword(NEW_PASSWORD);
+
+ return form;
+ }
+
+ /**
+ * A non-admin user should be able to change his password.
+ */
+ @Test
+ void testChangePassword() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/password", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(UNVERIFIED_USER_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(changePasswordForm(USER_PASSWORD)), ChangePasswordForm.class)
+ .exchange()
+ .expectStatus().isNoContent()
+ .expectHeader().exists(LecUtils.TOKEN_RESPONSE_HEADER_NAME);
+
+ // Ensure able to login with new password
+ testUtils.login(UNVERIFIED_USER_EMAIL, NEW_PASSWORD);
+ }
+
+
+ /**
+ * An good admin user should be able to change the password of another user.
+ */
+ @Test
+ void testAdminChangePasswordAnotherUser() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/password", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(changePasswordForm(ADMIN_PASSWORD)), ChangePasswordForm.class)
+ .exchange()
+ .expectStatus().isNoContent()
+ .expectHeader().exists(LecUtils.TOKEN_RESPONSE_HEADER_NAME);
+
+ // Ensure able to login with new password
+ testUtils.login(UNVERIFIED_USER_EMAIL, NEW_PASSWORD);
+ }
+
+ /**
+ * Providing an unknown id should return 404.
+ */
+ @Test
+ void testChangePasswordUnknownId() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/password", ObjectId.get())
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(changePasswordForm(ADMIN_PASSWORD)), ChangePasswordForm.class)
+ .exchange()
+ .expectStatus().isNotFound()
+ .expectHeader().doesNotExist(LecUtils.TOKEN_RESPONSE_HEADER_NAME);
+ }
+
+ /**
+ * A non-admin user should not be able to change others' password.
+ */
+ @Test
+ void testChangePasswordAnotherUser() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/password", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(USER_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(changePasswordForm(USER_PASSWORD)), ChangePasswordForm.class)
+ .exchange()
+ .expectStatus().isForbidden()
+ .expectHeader().doesNotExist(LecUtils.TOKEN_RESPONSE_HEADER_NAME);
+
+ // Ensure password didn't change
+ testUtils.login(UNVERIFIED_USER_EMAIL, USER_PASSWORD);
+ }
+
+
+ /**
+ * A bad admin user should not be able to change others' password.
+ */
+ @Test
+ void testBadAdminChangePasswordAnotherUser() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/password", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(UNVERIFIED_ADMIN_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(changePasswordForm(ADMIN_PASSWORD)), ChangePasswordForm.class)
+ .exchange()
+ .expectStatus().isForbidden()
+ .expectHeader().doesNotExist(LecUtils.TOKEN_RESPONSE_HEADER_NAME);
+
+ // Ensure password didn't change
+ testUtils.login(UNVERIFIED_USER_EMAIL, USER_PASSWORD);
+ }
+
+
+ @Test
+ void testChangePasswordInvalidData() throws Exception {
+
+ //@formatter:off
+ CLIENT.post().uri(BASE_URI + "/users/{id}/password", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(new ChangePasswordForm()), ChangePasswordForm.class)
+ .exchange()
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY)
+ .expectHeader().doesNotExist(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody(TestErrorResponse.class)
+ .consumeWith(errorResponseResult -> {
+ assertErrors(errorResponseResult,
+ "changePasswordFormMono.oldPassword",
+ "changePasswordFormMono.retypePassword",
+ "changePasswordFormMono.password");
+ });
+ //@formatter:on
+
+ // Ensure password didn't change
+ testUtils.login(UNVERIFIED_USER_EMAIL, USER_PASSWORD);
+
+ // All fields too short
+ ChangePasswordForm form = new ChangePasswordForm();
+ form.setOldPassword("short");
+ form.setPassword("short");
+ form.setRetypePassword("short");
+
+ //@formatter:off
+ CLIENT.post().uri(BASE_URI + "/users/{id}/password", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(form), ChangePasswordForm.class)
+ .exchange()
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY)
+ .expectHeader().doesNotExist(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody(TestErrorResponse.class)
+ .consumeWith(errorResponseResult -> {
+ assertErrors(errorResponseResult,
+ "changePasswordFormMono.oldPassword",
+ "changePasswordFormMono.retypePassword",
+ "changePasswordFormMono.password");
+ });
+ //@formatter:on
+
+ // Ensure password didn't change
+ testUtils.login(UNVERIFIED_USER_EMAIL, USER_PASSWORD);
+
+ // different retype-password
+ form = changePasswordForm(USER_PASSWORD);
+ form.setRetypePassword("different-retype-password");
+
+ //@formatter:off
+ CLIENT.post().uri(BASE_URI + "/users/{id}/password", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(form), ChangePasswordForm.class)
+ .exchange()
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY)
+ .expectHeader().doesNotExist(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody(TestErrorResponse.class)
+ .consumeWith(errorResponseResult -> {
+ assertErrors(errorResponseResult,
+ "changePasswordFormMono.retypePassword",
+ "changePasswordFormMono.password");
+ });
+ //@formatter:on
+
+ // Ensure password didn't change
+ testUtils.login(UNVERIFIED_USER_EMAIL, USER_PASSWORD);
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/FetchNewTokenTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/FetchNewTokenTests.java
new file mode 100644
index 00000000..5caa9c93
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/FetchNewTokenTests.java
@@ -0,0 +1,94 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemondemo.dto.TestToken;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.springframework.web.reactive.function.BodyInserters.fromFormData;
+
+class FetchNewTokenTests extends AbstractTests {
+
+ @Test
+ void testFetchNewToken() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/fetch-new-auth-token")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(UNVERIFIED_USER_ID))
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody(TestToken.class)
+ .consumeWith(this::ensureTokenWorks);
+ }
+
+
+ @Test
+ void testFetchNewTokenExpiration() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/fetch-new-auth-token")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(UNVERIFIED_USER_ID))
+ .body(fromFormData("expirationMillis", "1000"))
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody(TestToken.class)
+ .consumeWith(result -> {
+
+ ensureTokenWorks(result);
+ TestToken token = result.getResponseBody();
+
+ try {
+ Thread.sleep(1001L);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+
+ CLIENT.get()
+ .uri(BASE_URI + "/context")
+ .header(HttpHeaders.AUTHORIZATION, token.getToken())
+ .exchange()
+ .expectStatus().isUnauthorized();
+ });
+ }
+
+ @Test
+ void testFetchNewTokenByAdminForAnotherUser() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/fetch-new-auth-token")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .body(fromFormData("username", UNVERIFIED_USER_EMAIL))
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody(TestToken.class)
+ .consumeWith(this::ensureTokenWorks);
+ }
+
+
+ @Test
+ void testFetchNewTokenByNonAdminForAnotherUser() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/fetch-new-auth-token")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(UNVERIFIED_USER_ID))
+ .body(fromFormData("username", ADMIN_EMAIL))
+ .exchange()
+ .expectStatus().isForbidden()
+ .expectBody().jsonPath("$.token").doesNotExist();
+ }
+
+
+ private void ensureTokenWorks(EntityExchangeResult result) {
+
+ TestToken token = result.getResponseBody();
+ assertNotNull(token.getToken());
+
+ testUtils.contextResponse(token.getToken())
+ .expectBody()
+ .jsonPath("$.user.id").isEqualTo(UNVERIFIED_USER_ID.toString());
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/FetchUserTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/FetchUserTests.java
new file mode 100644
index 00000000..5826c4f4
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/FetchUserTests.java
@@ -0,0 +1,110 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import org.bson.types.ObjectId;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.springframework.web.reactive.function.BodyInserters.fromFormData;
+
+class FetchUserTests extends AbstractTests {
+
+ @Test
+ void testFetchUserById() throws Exception {
+
+ CLIENT.get().uri(BASE_URI + "/users/{id}", ADMIN_ID)
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody()
+ .jsonPath("$.id").isEqualTo(ADMIN_ID.toString())
+ .jsonPath("$.email").doesNotExist()
+ .jsonPath("$.password").doesNotExist()
+ .jsonPath("$.credentialsUpdatedAt").doesNotExist()
+ .jsonPath("$.name").isEqualTo("Admin 1");
+ }
+
+ @Test
+ void testFetchUserByIdLoggedIn() throws Exception {
+
+ // Same user logged in
+ CLIENT.get().uri(BASE_URI + "/users/{id}", ADMIN_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody()
+ .jsonPath("$.id").isEqualTo(ADMIN_ID.toString())
+ .jsonPath("$.email").isEqualTo(ADMIN_EMAIL)
+ .jsonPath("$.password").doesNotExist()
+ .jsonPath("$.credentialsUpdatedAt").doesNotExist()
+ .jsonPath("$.name").isEqualTo("Admin 1");
+
+ // Another user logged in
+ CLIENT.get().uri(BASE_URI + "/users/{id}", ADMIN_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(UNVERIFIED_USER_ID))
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody()
+ .jsonPath("$.id").isEqualTo(ADMIN_ID.toString())
+ .jsonPath("$.email").doesNotExist();
+
+ // Admin user logged in - fetching another user
+ CLIENT.get().uri(BASE_URI + "/users/{id}", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody()
+ .jsonPath("$.id").isEqualTo(UNVERIFIED_USER_ID.toString())
+ .jsonPath("$.email").isEqualTo(UNVERIFIED_USER_EMAIL);
+ }
+
+ @Test
+ void testFetchNonExistingUserById() throws Exception {
+
+ CLIENT.get().uri(BASE_URI + "/users/{id}", ObjectId.get())
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+ @Test
+ void testFetchUserByEmail() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/fetch-by-email")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .body(fromFormData("email", ADMIN_EMAIL))
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody()
+ .jsonPath("$.id").isEqualTo(ADMIN_ID.toString())
+ .jsonPath("$.password").doesNotExist()
+ .jsonPath("$.credentialsUpdatedAt").doesNotExist()
+ .jsonPath("$.name").isEqualTo("Admin 1");
+ }
+
+ @Test
+ void testFetchUserByInvalidEmail() throws Exception {
+
+ // email does not exist
+ CLIENT.post().uri(BASE_URI + "/users/fetch-by-email")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .body(fromFormData("email", "foo@example.com"))
+ .exchange()
+ .expectStatus().isNotFound();
+
+ // Blank email
+ CLIENT.post().uri(BASE_URI + "/users/fetch-by-email")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .body(fromFormData("email", ""))
+ .exchange()
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+
+ // Invalid email
+ CLIENT.post().uri(BASE_URI + "/users/fetch-by-email")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .body(fromFormData("email", "invalid-email"))
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ForgotPasswordTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ForgotPasswordTests.java
new file mode 100644
index 00000000..b3682d80
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ForgotPasswordTests.java
@@ -0,0 +1,61 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.ADMIN_EMAIL;
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.CLIENT;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.springframework.web.reactive.function.BodyInserters.fromFormData;
+
+class ForgotPasswordTests extends AbstractTests {
+
+ @Test
+ void testForgotPassword() {
+
+ CLIENT.post().uri(BASE_URI + "/forgot-password")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .body(fromFormData("email", ADMIN_EMAIL))
+ .exchange()
+ .expectStatus().isNoContent();
+
+ verify(mailSender).send(any());
+ }
+
+ @Test
+ void testForgotPasswordInvalidEmail() throws Exception {
+
+ // Unknown email
+ CLIENT.post().uri(BASE_URI + "/forgot-password")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .body(fromFormData("email", "unknown@example.com"))
+ .exchange()
+ .expectStatus().isNotFound();
+
+ // Null email
+ CLIENT.post().uri(BASE_URI + "/forgot-password")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .exchange()
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+
+ // Blank email
+ CLIENT.post().uri(BASE_URI + "/forgot-password")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .body(fromFormData("email", ""))
+ .exchange()
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+
+ // Wrong email format
+ CLIENT.post().uri(BASE_URI + "/forgot-password")
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .body(fromFormData("email", "wrong-email-format"))
+ .exchange()
+ .expectStatus().isNotFound();
+
+ verify(mailSender, never()).send(any());
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/LemonDemoReactiveApplicationTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/LemonDemoReactiveApplicationTests.java
deleted file mode 100644
index 16d1772b..00000000
--- a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/LemonDemoReactiveApplicationTests.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.naturalprogrammer.spring.lemondemo;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.test.context.junit4.SpringRunner;
-
-@RunWith(SpringRunner.class)
-@SpringBootTest
-public class LemonDemoReactiveApplicationTests {
-
- @Test
- public void contextLoads() {
- }
-
-}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/LoginTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/LoginTests.java
new file mode 100644
index 00000000..a6de6ab3
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/LoginTests.java
@@ -0,0 +1,129 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemondemo.domain.User;
+import com.naturalprogrammer.spring.lemondemo.dto.TestUserDto;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.springframework.web.reactive.function.BodyInserters.fromFormData;
+
+class LoginTests extends AbstractTests {
+
+ @Test
+ void testLogin() {
+
+ testUtils.loginResponse(ADMIN_EMAIL, ADMIN_PASSWORD)
+ .expectStatus().isOk()
+ .expectBody(TestUserDto.class)
+ .consumeWith(result -> {
+
+ TestUserDto user = result.getResponseBody();
+
+ assertEquals(ADMIN_ID, user.getId());
+ assertNull(user.getPassword());
+ assertEquals("admin@example.com", user.getUsername());
+ assertEquals(1, user.getRoles().size());
+ assertTrue(user.getRoles().contains("ADMIN"));
+ assertEquals("Admin 1", user.getTag().getName());
+ assertFalse(user.isUnverified());
+ assertFalse(user.isBlocked());
+ assertTrue(user.isAdmin());
+ assertTrue(user.isGoodUser());
+ assertTrue(user.isGoodAdmin());
+ });
+ }
+
+
+ @Test
+ void testLoginTokenExpiry() throws Exception {
+
+ String token = login(ADMIN_EMAIL, ADMIN_PASSWORD, 500L);
+ Thread.sleep(501L);
+
+ CLIENT.get()
+ .uri(BASE_URI + "/ping")
+ .header(HttpHeaders.AUTHORIZATION, token)
+ .exchange()
+ .expectStatus().isUnauthorized();
+ }
+
+
+ @Test
+ void testObsoleteToken() throws Exception {
+
+ User user = mongoTemplate.findById(ADMIN_ID, User.class).block();
+ user.setCredentialsUpdatedMillis(System.currentTimeMillis());
+ mongoTemplate.save(user).block();
+
+ CLIENT.get()
+ .uri(BASE_URI + "/ping")
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .exchange()
+ .expectStatus().isUnauthorized();
+ }
+
+
+ @Test
+ void testLoginWrongPassword() throws Exception {
+
+ testUtils.loginResponse(ADMIN_EMAIL, "wrong-password")
+ .expectStatus().isUnauthorized();
+ }
+
+
+ @Test
+ void testLoginBlankPassword() throws Exception {
+
+ testUtils.loginResponse(ADMIN_EMAIL, "")
+ .expectStatus().isUnauthorized();
+ }
+
+
+ @Test
+ void testTokenLogin() {
+
+ CLIENT.get().uri(BASE_URI + "/context")
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody()
+ .jsonPath("$.user.id").isEqualTo(ADMIN_ID.toString());
+ }
+
+
+ @Test
+ void testTokenLoginWrongToken() {
+
+ CLIENT.get().uri(BASE_URI + "/context")
+ .header(HttpHeaders.AUTHORIZATION, "Bearer a-wrong-token")
+ .exchange()
+ .expectStatus().isUnauthorized();
+ }
+
+
+ @Test
+ void testLogout() throws Exception {
+
+ CLIENT.post().uri("/logout")
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+
+ private String login(String username, String password, long expirationMillis) {
+
+ return CLIENT.post()
+ .uri(BASE_URI + "/login")
+ .body(fromFormData("username", username)
+ .with("password", password)
+ .with("expirationMillis", Long.toString(expirationMillis)))
+ .exchange()
+ .returnResult(TestUserDto.class)
+ .getResponseHeaders()
+ .getFirst(LecUtils.TOKEN_RESPONSE_HEADER_NAME);
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/MyTestUtils.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/MyTestUtils.java
new file mode 100644
index 00000000..26696ead
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/MyTestUtils.java
@@ -0,0 +1,119 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemondemo.domain.User;
+import com.naturalprogrammer.spring.lemondemo.dto.TestUserDto;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.bson.types.ObjectId;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
+import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.springframework.web.reactive.function.BodyInserters.fromFormData;
+
+@Component
+@Slf4j
+public class MyTestUtils {
+
+ public static final ObjectId ADMIN_ID = ObjectId.get();
+ public static final ObjectId UNVERIFIED_ADMIN_ID = ObjectId.get();
+ public static final ObjectId BLOCKED_ADMIN_ID = ObjectId.get();
+
+ public static final ObjectId USER_ID = ObjectId.get();
+ public static final ObjectId UNVERIFIED_USER_ID = ObjectId.get();
+ public static final ObjectId BLOCKED_USER_ID = ObjectId.get();
+
+ public static final String ADMIN_EMAIL = "admin@example.com";
+ public static final String ADMIN_PASSWORD = "admin!";
+
+ public static final String USER_PASSWORD = "Sanjay99!";
+ public static final String UNVERIFIED_USER_EMAIL = "unverifieduser@example.com";
+
+ public static final Map TOKENS = new HashMap<>(6);
+
+ public static WebTestClient CLIENT;
+
+ @Autowired
+ private PasswordEncoder passwordEncoder;
+
+ @Autowired
+ private ReactiveMongoTemplate mongoTemplate;
+
+ public MyTestUtils(ApplicationContext context) {
+ CLIENT = WebTestClient
+ .bindToApplicationContext(context)
+ .configureClient()
+ .responseTimeout(Duration.ofHours(1L))
+ .build();
+ }
+
+ public String login(String userName, String password) {
+
+ return loginResponse(userName, password)
+ .expectStatus().isOk()
+ .returnResult(TestUserDto.class)
+ .getResponseHeaders()
+ .getFirst(LecUtils.TOKEN_RESPONSE_HEADER_NAME);
+ }
+
+ public ResponseSpec loginResponse(String userName, String password) {
+
+ return CLIENT.post()
+ .uri(BASE_URI + "/login")
+ .body(fromFormData("username", userName)
+ .with("password", password))
+ .exchange();
+ }
+
+ public ResponseSpec contextResponse(String token) {
+
+ return CLIENT.get()
+ .uri(BASE_URI + "/context")
+ .header(HttpHeaders.AUTHORIZATION, token)
+ .exchange()
+ .expectStatus().isOk();
+ }
+
+ public void initDatabase() {
+
+ mongoTemplate.dropCollection("usr").block();
+ log.debug("Creating users ... ");
+
+ createUser(ADMIN_ID, ADMIN_EMAIL, ADMIN_PASSWORD, "Admin 1", "ADMIN");
+ createUser(UNVERIFIED_ADMIN_ID, "unverifiedadmin@example.com", ADMIN_PASSWORD, "Unverified Admin", "ADMIN", "UNVERIFIED");
+ createUser(BLOCKED_ADMIN_ID, "blockedadmin@example.com", ADMIN_PASSWORD, "Blocked Admin", "ADMIN", "BLOCKED");
+ createUser(USER_ID, "user@example.com", USER_PASSWORD, "User");
+ createUser(UNVERIFIED_USER_ID, UNVERIFIED_USER_EMAIL, USER_PASSWORD, "Unverified User", "UNVERIFIED");
+ createUser(BLOCKED_USER_ID, "blockeduser@example.com", USER_PASSWORD, "Blocked User", "BLOCKED");
+
+ log.debug("Created users.");
+ }
+
+ private void createUser(ObjectId id, String email, String password, String name, String... roles) {
+
+ User user = new User();
+ user.setId(id);
+ user.setEmail(email);
+ user.setPassword(passwordEncoder.encode(password));
+ user.setName(name);
+ user.setCredentialsUpdatedMillis(0L);
+ user.setRoles(new HashSet(Arrays.asList(roles)));
+
+ mongoTemplate.insert(user).block();
+ TOKENS.put(user.getId(), login(user.getEmail(), password));
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/RequestEmailChangeTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/RequestEmailChangeTests.java
new file mode 100644
index 00000000..153e355b
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/RequestEmailChangeTests.java
@@ -0,0 +1,218 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.naturalprogrammer.spring.lemondemo.domain.User;
+import com.naturalprogrammer.spring.lemondemo.dto.TestEmailForm;
+import com.naturalprogrammer.spring.lemondemo.dto.TestErrorResponse;
+import org.bson.types.ObjectId;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient.BodySpec;
+import reactor.core.publisher.Mono;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+class RequestEmailChangeTests extends AbstractTests {
+
+ private static final String NEW_EMAIL = "new.email@example.com";
+
+ private TestEmailForm form() {
+
+ TestEmailForm emailForm = new TestEmailForm();
+ emailForm.setPassword(USER_PASSWORD);
+ emailForm.setNewEmail(NEW_EMAIL);
+
+ return emailForm;
+ }
+
+
+ @Test
+ void testRequestEmailChange() {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/email-change-request", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(UNVERIFIED_USER_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(form()), TestEmailForm.class)
+ .exchange()
+ .expectStatus().isNoContent();
+
+ verify(mailSender).send(any());
+
+ User updatedUser = mongoTemplate.findById(UNVERIFIED_USER_ID, User.class).block();
+ assertEquals(NEW_EMAIL, updatedUser.getNewEmail());
+ assertEquals(UNVERIFIED_USER_EMAIL, updatedUser.getEmail());
+ }
+
+
+ /**
+ * A good admin should be able to request changing email of another user.
+ */
+ @Test
+ void testGoodAdminRequestEmailChange() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/email-change-request", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(form()), TestEmailForm.class)
+ .exchange()
+ .expectStatus().isNoContent();
+
+ User updatedUser = mongoTemplate.findById(UNVERIFIED_USER_ID, User.class).block();
+ assertEquals(NEW_EMAIL, updatedUser.getNewEmail());
+ }
+
+
+ /**
+ * A request changing email of unknown user.
+ */
+ @Test
+ void testRequestEmailChangeUnknownUser() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/email-change-request", ObjectId.get())
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(ADMIN_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(form()), TestEmailForm.class)
+ .exchange()
+ .expectStatus().isNotFound();
+
+ verify(mailSender, never()).send(any());
+ }
+
+
+ /**
+ * A non-admin should not be able to request changing
+ * the email id of another user
+ */
+ @Test
+ void testNonAdminRequestEmailChangeAnotherUser() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/email-change-request", ADMIN_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(USER_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(form()), TestEmailForm.class)
+ .exchange()
+ .expectStatus().isForbidden();
+
+ assertNotChanged();
+ verify(mailSender, never()).send(any());
+ }
+
+
+ /**
+ * A bad admin trying to change the email id
+ * of another user
+ */
+ @Test
+ void testBadAdminRequestEmailChangeAnotherUser() throws Exception {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/email-change-request", ADMIN_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(UNVERIFIED_ADMIN_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(form()), TestEmailForm.class)
+ .exchange()
+ .expectStatus().isForbidden();
+
+ assertNotChanged();
+ verify(mailSender, never()).send(any());
+ }
+
+
+ /**
+ * Trying with invalid data.
+ */
+ @Test
+ void testRequestEmailChangeWithInvalidData() {
+
+ // try with null newEmail and password
+ tryRequestingEmailChangeBodySpec(new TestEmailForm())
+ .consumeWith(errorResponseResult -> {
+ assertErrors(errorResponseResult,
+ "emailFormMono.newEmail",
+ "emailFormMono.password");
+ });
+ assertNotChanged();
+
+ // try with blank newEmail and password
+ TestEmailForm emailForm = new TestEmailForm();
+ emailForm.setPassword("");
+ emailForm.setNewEmail("");
+ tryRequestingEmailChangeBodySpec(emailForm)
+ .consumeWith(errorResponseResult -> {
+ assertErrors(errorResponseResult,
+ "emailFormMono.newEmail",
+ "emailFormMono.newEmail",
+ "emailFormMono.password",
+ "emailFormMono.password");
+ });
+
+ // try with invalid newEmail
+ emailForm = form();
+ emailForm.setNewEmail("an-invalid-email");
+ tryRequestingEmailChangeBodySpec(emailForm)
+ .consumeWith(errorResponseResult -> {
+ assertErrors(errorResponseResult,
+ "emailFormMono.newEmail");
+ });
+ assertNotChanged();
+
+ // try with wrong password
+ emailForm = form();
+ emailForm.setPassword("wrong-password");
+ tryRequestingEmailChangeBodySpec(emailForm)
+ .consumeWith(errorResponseResult -> {
+ assertErrors(errorResponseResult,
+ "emailFormMono.password");
+ });
+ assertNotChanged();
+
+ // try with null password
+ emailForm = form();
+ emailForm.setPassword(null);
+ tryRequestingEmailChangeBodySpec(emailForm)
+ .consumeWith(errorResponseResult -> {
+ assertErrors(errorResponseResult,
+ "emailFormMono.password");
+ });
+ assertNotChanged();
+
+ // try with an existing email
+ emailForm = form();
+ emailForm.setNewEmail(ADMIN_EMAIL);;
+ tryRequestingEmailChangeBodySpec(emailForm)
+ .consumeWith(errorResponseResult -> {
+ assertErrors(errorResponseResult,
+ "emailFormMono.newEmail");
+ });
+ assertNotChanged();
+
+ verify(mailSender, never()).send(any());
+ }
+
+
+ private BodySpec tryRequestingEmailChangeBodySpec(TestEmailForm form) {
+
+ //@formatter:off
+ return CLIENT.post().uri(BASE_URI + "/users/{id}/email-change-request", UNVERIFIED_USER_ID)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(UNVERIFIED_USER_ID))
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(form), TestEmailForm.class)
+ .exchange()
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY)
+ .expectBody(TestErrorResponse.class);
+ //@formatter:on
+ }
+
+
+ private void assertNotChanged() {
+ User updatedUser = mongoTemplate.findById(UNVERIFIED_USER_ID, User.class).block();
+ assertNull(updatedUser.getNewEmail());
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ResendVerificationMailTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ResendVerificationMailTests.java
new file mode 100644
index 00000000..6c1039e9
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ResendVerificationMailTests.java
@@ -0,0 +1,96 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import org.bson.types.ObjectId;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+class ResendVerificationMailTests extends AbstractTests {
+
+ @Test
+ void testResendVerificationMail() {
+
+ resendVerificationMail(UNVERIFIED_USER_ID, UNVERIFIED_USER_ID)
+ .expectStatus().isNoContent();
+
+ verify(mailSender).send(any());
+ }
+
+
+ @Test
+ void testAdminResendVerificationMailOtherUser() {
+
+ resendVerificationMail(UNVERIFIED_USER_ID, ADMIN_ID)
+ .expectStatus().isNoContent();
+ }
+
+
+ @Test
+ void testBadAdminResendVerificationMailOtherUser() {
+
+ resendVerificationMail(UNVERIFIED_USER_ID, UNVERIFIED_ADMIN_ID)
+ .expectStatus().isForbidden();
+
+ resendVerificationMail(UNVERIFIED_USER_ID, BLOCKED_ADMIN_ID)
+ .expectStatus().isForbidden();
+
+ verify(mailSender, never()).send(any());
+ }
+
+
+ @Test
+ void testResendVerificationMailUnauthenticated() {
+
+ CLIENT.post().uri(BASE_URI + "/users/{id}/resend-verification-mail", UNVERIFIED_USER_ID)
+ .exchange()
+ .expectStatus().isForbidden();
+
+ verify(mailSender, never()).send(any());
+ }
+
+
+ @Test
+ void testResendVerificationMailAlreadyVerified() {
+
+ resendVerificationMail(USER_ID, USER_ID)
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+
+ verify(mailSender, never()).send(any());
+ }
+
+
+ @Test
+ void testResendVerificationMailOtherUser() {
+
+ resendVerificationMail(UNVERIFIED_USER_ID, USER_ID)
+ .expectStatus().isForbidden();
+
+ verify(mailSender, never()).send(any());
+ }
+
+
+ @Test
+ void testResendVerificationMailNonExistingUser() {
+
+ resendVerificationMail(ObjectId.get(), ADMIN_ID)
+ .expectStatus().isNotFound();
+
+ verify(mailSender, never()).send(any());
+ }
+
+
+ private ResponseSpec resendVerificationMail(ObjectId userId, ObjectId loggedInId) {
+
+ return CLIENT.post().uri(BASE_URI + "/users/{id}/resend-verification-mail", userId)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(loggedInId))
+ .exchange();
+
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ResetPasswordTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ResetPasswordTests.java
new file mode 100644
index 00000000..9d8276ec
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/ResetPasswordTests.java
@@ -0,0 +1,82 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemon.commons.security.GreenTokenService;
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemondemo.dto.TestResetPasswordForm;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec;
+import reactor.core.publisher.Mono;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+
+class ResetPasswordTests extends AbstractTests {
+
+ private String forgotPasswordCode;
+
+ @Autowired
+ private GreenTokenService greenTokenService;
+
+ @BeforeEach
+ public void setUp() {
+
+ forgotPasswordCode = greenTokenService.createToken(
+ GreenTokenService.FORGOT_PASSWORD_AUDIENCE,
+ ADMIN_EMAIL, 60000L);
+ }
+
+ @Test
+ void testResetPassword() throws Exception {
+
+ final String NEW_PASSWORD = "newPassword!";
+
+ resetPassword(forgotPasswordCode, NEW_PASSWORD)
+ .expectStatus().isOk()
+ .expectHeader().exists(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody().jsonPath("$.id").isEqualTo(ADMIN_ID.toString());
+
+ // Ensure able to login with new password
+ testUtils.login(ADMIN_EMAIL, NEW_PASSWORD);
+
+ // Repeating shouldn't work
+ resetPassword(forgotPasswordCode, NEW_PASSWORD)
+ .expectStatus().isUnauthorized();
+ }
+
+ @Test
+ void testResetPasswordInvalidData() throws Exception {
+
+ // Wrong code
+ resetPassword("wrong-code", "abc99!")
+ .expectStatus().isUnauthorized();
+
+ // Blank password
+ resetPassword(forgotPasswordCode, "")
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+
+ // Invalid password
+ resetPassword(forgotPasswordCode, "abc")
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+ }
+
+ private ResponseSpec resetPassword(String forgotPasswordCode, String newPassword) {
+
+ return CLIENT.post().uri(BASE_URI + "/reset-password")
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(form(forgotPasswordCode, newPassword)), TestResetPasswordForm.class)
+ .exchange();
+ }
+
+ private TestResetPasswordForm form(String code, String newPassword) {
+
+ TestResetPasswordForm form = new TestResetPasswordForm();
+ form.setCode(code);
+ form.setNewPassword(newPassword);
+
+ return form;
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/SignupTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/SignupTests.java
new file mode 100644
index 00000000..48b319af
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/SignupTests.java
@@ -0,0 +1,112 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemondemo.domain.User;
+import com.naturalprogrammer.spring.lemondemo.dto.TestErrorResponse;
+import com.naturalprogrammer.spring.lemondemo.dto.TestLemonFieldError;
+import com.naturalprogrammer.spring.lemondemo.dto.TestUser;
+import com.naturalprogrammer.spring.lemondemo.dto.TestUserDto;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec;
+import reactor.core.publisher.Mono;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.stream.Collectors;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.springframework.data.mongodb.core.query.Criteria.where;
+import static org.springframework.data.mongodb.core.query.Query.query;
+
+class SignupTests extends AbstractTests {
+
+ @Test
+ void testSignup() throws Exception {
+
+ signup("user.foo@example.com", "user123", "User Foo")
+ .expectStatus().isCreated()
+ .expectHeader().exists(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody(TestUserDto.class)
+ .consumeWith(result -> {
+ TestUserDto userDto = result.getResponseBody();
+ assertNotNull(userDto.getId());
+ assertNull(userDto.getPassword());
+ assertEquals("user.foo@example.com", userDto.getUsername());
+ assertEquals(1, userDto.getRoles().size());
+ assertTrue(userDto.getRoles().contains("UNVERIFIED"));
+ assertEquals("User Foo", userDto.getTag().getName());
+ assertTrue(userDto.isUnverified());
+ assertFalse(userDto.isBlocked());
+ assertFalse(userDto.isAdmin());
+ assertFalse(userDto.isGoodUser());
+ assertFalse(userDto.isGoodAdmin());
+ });
+
+ verify(mailSender).send(any());
+
+ // Ensure that password got encrypted
+ assertNotEquals("user123",
+ mongoTemplate.findOne(query(where("email").is("user.foo@example.com")),
+ User.class).block().getPassword());
+ }
+
+ @Test
+ void testSignupWithInvalidData() throws Exception {
+
+ signup("abc", "user1", null)
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY)
+ .expectBody(TestErrorResponse.class)
+ .consumeWith(errorResponseResult -> {
+ assertErrors(errorResponseResult,
+ "userMono.email",
+ "userMono.email",
+ "userMono.password",
+ "userMono.name");
+
+ Collection errors = errorResponseResult.getResponseBody().getErrors();
+ assertTrue(errors.stream()
+ .map(TestLemonFieldError::getCode).collect(Collectors.toSet())
+ .containsAll(Arrays.asList(
+ "NotBlank",
+ "Size",
+ "Email")));
+
+ assertTrue(errors.stream()
+ .map(TestLemonFieldError::getMessage).collect(Collectors.toSet())
+ .containsAll(Arrays.asList(
+ "Not a well formed email address",
+ "Name required",
+ "Email must be between 4 and 250 characters",
+ "Password must be between 6 and 50 characters")));
+ });
+
+ verify(mailSender, never()).send(any());
+ }
+
+ @Test
+ void testSignupDuplicateEmail() throws Exception {
+
+ signup(ADMIN_EMAIL, "user123", "User Foo")
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+
+ verify(mailSender, never()).send(any());
+ }
+
+ private ResponseSpec signup(String email, String password, String name) {
+
+ TestUser user = new TestUser(email, password, name);
+
+ return CLIENT.post().uri(BASE_URI + "/users", UNVERIFIED_USER_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Mono.just(user), TestUser.class)
+ .exchange();
+ }
+
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/TestConfiguration.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/TestConfiguration.java
new file mode 100644
index 00000000..a163c84d
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/TestConfiguration.java
@@ -0,0 +1,14 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.mongodb.MongoDatabaseFactory;
+import org.springframework.data.mongodb.core.MongoTemplate;
+
+@Configuration
+public class TestConfiguration {
+
+ //@Bean
+ public MongoTemplate mongoTemplate(MongoDatabaseFactory mongoDbFactory) {
+ return new MongoTemplate(mongoDbFactory);
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/UpdateUserTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/UpdateUserTests.java
new file mode 100644
index 00000000..9d498563
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/UpdateUserTests.java
@@ -0,0 +1,188 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemon.commons.util.UserUtils;
+import com.naturalprogrammer.spring.lemondemo.domain.User;
+import com.naturalprogrammer.spring.lemondemo.dto.TestUserDto;
+import org.bson.types.ObjectId;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec;
+import org.springframework.web.reactive.function.BodyInserters;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class UpdateUserTests extends AbstractTests {
+
+ private static final String UPDATED_NAME = "Edited name";
+
+ @Value("classpath:/update-user/patch-update-user.json")
+ private Resource userPatch;
+
+ @Value("classpath:/update-user/patch-admin-role.json")
+ private Resource userPatchAdminRole;
+
+ @Value("classpath:/update-user/patch-null-name.json")
+ private Resource userPatchNullName;
+
+ @Value("classpath:/update-user/patch-long-name.json")
+ private Resource userPatchLongName;
+
+ /**
+ * A non-admin user should be able to update his own name,
+ * but changes in roles should be skipped.
+ * The name of security principal object should also
+ * change in the process.
+ */
+ @Test
+ void testUpdateSelf() {
+
+ updateUser(UNVERIFIED_USER_ID, UNVERIFIED_USER_ID, userPatch)
+ .expectStatus().isOk()
+ .expectHeader().exists(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody(TestUserDto.class)
+ .consumeWith(result -> {
+
+ TestUserDto userDto = result.getResponseBody();
+
+ assertEquals(UNVERIFIED_USER_ID, userDto.getId());
+ assertEquals(UNVERIFIED_USER_EMAIL, userDto.getUsername());
+ assertEquals(UPDATED_NAME, userDto.getTag().getName());
+ assertEquals(1, userDto.getRoles().size());
+ assertTrue(userDto.getRoles().contains(UserUtils.Role.UNVERIFIED));
+ });
+
+ User user = mongoTemplate.findById(UNVERIFIED_USER_ID, User.class).block();
+
+ // Ensure that data changed properly
+ assertEquals(UNVERIFIED_USER_EMAIL, user.getEmail());
+ assertEquals(1, user.getRoles().size());
+ assertTrue(user.getRoles().contains(UserUtils.Role.UNVERIFIED));
+ assertEquals(1L, user.getVersion().longValue());
+
+ // Version mismatch
+ updateUser(UNVERIFIED_USER_ID, UNVERIFIED_USER_ID, userPatch)
+ .expectStatus().isEqualTo(HttpStatus.CONFLICT);
+ }
+
+
+ /**
+ * A good ADMIN should be able to update another user's name and roles.
+ * The name of security principal object should NOT change in the process,
+ * and the verification code should get set/unset on addition/deletion of
+ * the UNVERIFIED role.
+ */
+ @Test
+ void testGoodAdminCanUpdateOther() {
+
+ updateUser(UNVERIFIED_USER_ID, ADMIN_ID, userPatch)
+ .expectStatus().isOk()
+ .expectHeader().exists(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody(TestUserDto.class)
+ .consumeWith(result -> {
+
+ TestUserDto userDto = result.getResponseBody();
+
+ assertEquals(UNVERIFIED_USER_ID, userDto.getId());
+ assertEquals(UNVERIFIED_USER_EMAIL, userDto.getUsername());
+ assertEquals(UPDATED_NAME, userDto.getTag().getName());
+ assertEquals(1, userDto.getRoles().size());
+ assertTrue(userDto.getRoles().contains(UserUtils.Role.ADMIN));
+ });
+
+ User user = mongoTemplate.findById(UNVERIFIED_USER_ID, User.class).block();
+
+ // Ensure that data changed properly
+ assertEquals(UNVERIFIED_USER_EMAIL, user.getEmail());
+ assertEquals(1, user.getRoles().size());
+ assertTrue(user.getRoles().contains(UserUtils.Role.ADMIN));
+ }
+
+ /**
+ * Providing an unknown id should return 404.
+ */
+ @Test
+ void testUpdateUnknownId() {
+
+ updateUser(ObjectId.get(), ADMIN_ID, userPatch)
+ .expectStatus().isNotFound();
+ }
+
+ /**
+ * A non-admin trying to update the name and roles of another user should throw exception
+ */
+ @Test
+ void testUpdateAnotherUser() {
+
+ updateUser(ADMIN_ID, UNVERIFIED_USER_ID, userPatch)
+ .expectStatus().isForbidden();
+ }
+
+ /**
+ * A bad ADMIN trying to update the name and roles of another user should throw exception
+ */
+ @Test
+ void testBadAdminUpdateAnotherUser() {
+
+ updateUser(UNVERIFIED_USER_ID, UNVERIFIED_ADMIN_ID, userPatch)
+ .expectStatus().isForbidden();
+
+ updateUser(UNVERIFIED_USER_ID, BLOCKED_ADMIN_ID, userPatch)
+ .expectStatus().isForbidden();
+ }
+
+ /**
+ * A good ADMIN should not be able to change his own roles
+ */
+ @Test
+ void goodAdminCanNotUpdateSelfRoles() {
+
+ updateUser(ADMIN_ID, ADMIN_ID, userPatchAdminRole)
+ .expectStatus().isOk()
+ .expectHeader().exists(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody(TestUserDto.class)
+ .consumeWith(result -> {
+
+ TestUserDto userDto = result.getResponseBody();
+
+ assertEquals(UPDATED_NAME, userDto.getTag().getName());
+ assertEquals(1, userDto.getRoles().size());
+ assertTrue(userDto.getRoles().contains(UserUtils.Role.ADMIN));
+ });
+
+ User user = mongoTemplate.findById(ADMIN_ID, User.class).block();
+
+ assertEquals(1, user.getRoles().size());
+ }
+
+ /**
+ * Invalid name
+ */
+ @Test
+ void testUpdateUserInvalidNewName() {
+
+ // Null name
+ updateUser(UNVERIFIED_USER_ID, ADMIN_ID, userPatchNullName)
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+
+ // Too long name
+ updateUser(UNVERIFIED_USER_ID, UNVERIFIED_USER_ID, userPatchLongName)
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+ }
+
+ private ResponseSpec updateUser(ObjectId userId, ObjectId loggedInId, Resource patch) {
+
+ return CLIENT.patch().uri(BASE_URI + "/users/{id}", userId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .header(HttpHeaders.AUTHORIZATION, TOKENS.get(loggedInId))
+ .body(BodyInserters.fromResource(patch))
+ .exchange();
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/VerificationTests.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/VerificationTests.java
new file mode 100644
index 00000000..51290c0f
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/VerificationTests.java
@@ -0,0 +1,103 @@
+package com.naturalprogrammer.spring.lemondemo;
+
+import com.naturalprogrammer.spring.lemon.commons.security.GreenTokenService;
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemondemo.dto.TestUserDto;
+import org.bson.types.ObjectId;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec;
+
+import static com.naturalprogrammer.spring.lemondemo.MyTestUtils.*;
+import static com.naturalprogrammer.spring.lemondemo.controllers.MyController.BASE_URI;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.springframework.web.reactive.function.BodyInserters.fromFormData;
+
+class VerificationTests extends AbstractTests {
+
+ private String verificationCode;
+
+ @Autowired
+ private GreenTokenService greenTokenService;
+
+ @BeforeEach
+ public void setUp() {
+
+ verificationCode = greenTokenService.createToken(GreenTokenService.VERIFY_AUDIENCE,
+ UNVERIFIED_USER_ID.toString(), 60000L,
+ LecUtils.mapOf("email", UNVERIFIED_USER_EMAIL));
+ }
+
+ @Test
+ void testEmailVerification() {
+
+ emailVerification(UNVERIFIED_USER_ID, verificationCode)
+ .expectStatus().isOk()
+ .expectHeader().exists(LecUtils.TOKEN_RESPONSE_HEADER_NAME)
+ .expectBody(TestUserDto.class)
+ .consumeWith(result -> {
+
+ TestUserDto userDto = result.getResponseBody();
+ assert userDto != null;
+
+ assertEquals(UNVERIFIED_USER_ID, userDto.getId());
+ assertEquals(0, userDto.getRoles().size());
+ assertTrue(userDto.isGoodUser());
+ assertFalse(userDto.isUnverified());
+ });
+
+ // Already verified
+ emailVerification(UNVERIFIED_USER_ID, verificationCode)
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+ }
+
+ @Test
+ void testEmailVerificationNonExistingUser() throws Exception {
+
+ emailVerification(ObjectId.get(), verificationCode)
+ .expectStatus().isNotFound();
+ }
+
+ @Test
+ void testEmailVerificationWrongToken() throws Exception {
+
+ // null token
+ CLIENT.post().uri(BASE_URI + "/users/{id}/verification", UNVERIFIED_USER_ID)
+ .exchange()
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+
+ // blank token
+ emailVerification(UNVERIFIED_USER_ID, "")
+ .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
+
+ // Wrong audience
+ String token = greenTokenService.createToken("wrong-audience",
+ UNVERIFIED_USER_ID.toString(), 60000L,
+ LecUtils.mapOf("email", UNVERIFIED_USER_EMAIL));
+ emailVerification(UNVERIFIED_USER_ID, token)
+ .expectStatus().isUnauthorized();
+
+ // Wrong email
+ token = greenTokenService.createToken(GreenTokenService.VERIFY_AUDIENCE,
+ UNVERIFIED_USER_ID.toString(), 60000L,
+ LecUtils.mapOf("email", "wrong.email@example.com"));
+ emailVerification(UNVERIFIED_USER_ID, token)
+ .expectStatus().isForbidden();
+
+ // expired token
+ token = greenTokenService.createToken(GreenTokenService.VERIFY_AUDIENCE,
+ UNVERIFIED_USER_ID.toString(), 1L,
+ LecUtils.mapOf("email", UNVERIFIED_USER_EMAIL));
+ emailVerification(UNVERIFIED_USER_ID, token)
+ .expectStatus().isUnauthorized();
+ }
+
+ private ResponseSpec emailVerification(ObjectId userId, String code) {
+
+ return CLIENT.post().uri(BASE_URI + "/users/{id}/verification", userId)
+ .body(fromFormData("code", code))
+ .exchange();
+ }
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestEmailForm.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestEmailForm.java
new file mode 100644
index 00000000..3cdd8297
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestEmailForm.java
@@ -0,0 +1,11 @@
+package com.naturalprogrammer.spring.lemondemo.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter @Setter
+public class TestEmailForm {
+
+ private String newEmail;
+ private String password;
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestErrorResponse.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestErrorResponse.java
new file mode 100644
index 00000000..4fb59bbb
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestErrorResponse.java
@@ -0,0 +1,20 @@
+package com.naturalprogrammer.spring.lemondemo.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Collection;
+
+/**
+ * Error DTO, to be sent as response body
+ * in case of errors
+ */
+@Getter @Setter
+public class TestErrorResponse {
+
+ private String exception;
+ private String error;
+ private String message;
+ private Integer status; // We'd need it as integer in JSON serialization
+ private Collection errors;
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestLemonFieldError.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestLemonFieldError.java
new file mode 100644
index 00000000..2aa8da3d
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestLemonFieldError.java
@@ -0,0 +1,13 @@
+package com.naturalprogrammer.spring.lemondemo.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter @Setter @NoArgsConstructor
+public class TestLemonFieldError {
+
+ private String field;
+ private String code;
+ private String message;
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestResetPasswordForm.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestResetPasswordForm.java
new file mode 100644
index 00000000..9f8191c9
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestResetPasswordForm.java
@@ -0,0 +1,11 @@
+package com.naturalprogrammer.spring.lemondemo.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter @Setter
+public class TestResetPasswordForm {
+
+ private String code;
+ private String newPassword;
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestToken.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestToken.java
new file mode 100644
index 00000000..c537f60b
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestToken.java
@@ -0,0 +1,10 @@
+package com.naturalprogrammer.spring.lemondemo.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter @Setter
+public class TestToken {
+
+ private String token;
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestUser.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestUser.java
new file mode 100644
index 00000000..448b3711
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestUser.java
@@ -0,0 +1,11 @@
+package com.naturalprogrammer.spring.lemondemo.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter @Setter @AllArgsConstructor
+public class TestUser {
+
+ private String email, password, name;
+}
diff --git a/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestUserDto.java b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestUserDto.java
new file mode 100644
index 00000000..d7bafc59
--- /dev/null
+++ b/lemon-demo-reactive/src/test/java/com/naturalprogrammer/spring/lemondemo/dto/TestUserDto.java
@@ -0,0 +1,25 @@
+package com.naturalprogrammer.spring.lemondemo.dto;
+
+import com.naturalprogrammer.spring.lemondemo.domain.User.Tag;
+import lombok.Getter;
+import lombok.Setter;
+import org.bson.types.ObjectId;
+
+import java.util.HashSet;
+import java.util.Set;
+
+@Getter @Setter
+public class TestUserDto {
+
+ private ObjectId id;
+ private String username;
+ private String password;
+ private Set roles = new HashSet();
+ private Tag tag;
+
+ private boolean unverified = false;
+ private boolean blocked = false;
+ private boolean admin = false;
+ private boolean goodUser = false;
+ private boolean goodAdmin = false;
+}
diff --git a/lemon-demo-reactive/src/test/resources/update-user/patch-admin-role.json b/lemon-demo-reactive/src/test/resources/update-user/patch-admin-role.json
new file mode 100644
index 00000000..541a71f9
--- /dev/null
+++ b/lemon-demo-reactive/src/test/resources/update-user/patch-admin-role.json
@@ -0,0 +1,6 @@
+[
+ {"op": "replace", "path": "/name", "value": "Edited name"},
+ {"op": "replace", "path": "/email", "value": "should.not@get.replaced"},
+ {"op": "replace", "path": "/roles", "value": []},
+ {"op": "replace", "path": "/version", "value": 0}
+]
diff --git a/lemon-demo-reactive/src/test/resources/update-user/patch-long-name.json b/lemon-demo-reactive/src/test/resources/update-user/patch-long-name.json
new file mode 100644
index 00000000..62e0d730
--- /dev/null
+++ b/lemon-demo-reactive/src/test/resources/update-user/patch-long-name.json
@@ -0,0 +1,4 @@
+[
+ {"op": "replace", "path": "/name", "value": "A123456789A123456789A123456789A123456789A123456789A123456789A123456789"},
+ {"op": "replace", "path": "/version", "value": 0}
+]
diff --git a/lemon-demo-reactive/src/test/resources/update-user/patch-null-name.json b/lemon-demo-reactive/src/test/resources/update-user/patch-null-name.json
new file mode 100644
index 00000000..9fc08cb5
--- /dev/null
+++ b/lemon-demo-reactive/src/test/resources/update-user/patch-null-name.json
@@ -0,0 +1,4 @@
+[
+ {"op": "replace", "path": "/name", "value": null},
+ {"op": "replace", "path": "/version", "value": 0}
+]
diff --git a/lemon-demo-reactive/src/test/resources/update-user/patch-update-user.json b/lemon-demo-reactive/src/test/resources/update-user/patch-update-user.json
new file mode 100644
index 00000000..228cc7c5
--- /dev/null
+++ b/lemon-demo-reactive/src/test/resources/update-user/patch-update-user.json
@@ -0,0 +1,6 @@
+[
+ {"op": "replace", "path": "/name", "value": "Edited name"},
+ {"op": "replace", "path": "/email", "value": "should.not@get.replaced"},
+ {"op": "replace", "path": "/roles", "value": ["ADMIN"]},
+ {"op": "replace", "path": "/version", "value": 0}
+]
diff --git a/pom.xml b/pom.xml
index 293011e7..676875c5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,95 +1,192 @@
-
-
- 4.0.0
-
- com.naturalprogrammer
- spring-lemon
- 1.0.0.M4
- pom
-
- spring-lemon
- Helper library for Spring Boot Web Applications
-
-
- org.springframework.boot
- spring-boot-starter-parent
- 2.0.3.RELEASE
-
-
-
-
- spring-lemon-exceptions
- spring-lemon-commons
- spring-lemon-jpa
- spring-lemon-reactive
- lemon-demo-jpa
- lemon-demo-reactive
-
-
-
-
-
- org.projectlombok
- lombok
- true
-
-
-
- org.springframework.boot
- spring-boot-starter-test
- test
-
-
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-source-plugin
-
-
- attach-sources
-
- jar
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-javadoc-plugin
-
- false
-
-
-
- attach-javadocs
-
- jar
-
-
-
-
-
-
-
-
-
-
- jitpack.io
- https://jitpack.io
-
-
-
-
- UTF-8
- UTF-8
- 1.8
-
-
-
+
+
+ 4.0.0
+
+ com.naturalprogrammer
+ spring-lemon
+ 1.0.2
+ pom
+
+ ${project.groupId}:${project.artifactId}
+ Helper library for Spring Boot Web Applications
+ https://github.com/naturalprogrammer/spring-lemon
+
+
+
+ Apache License, Version 2.0
+ https://www.apache.org/licenses/LICENSE-2.0.txt
+ repo
+
+
+
+
+
+ Sanjay Patel
+ skpatel20@gmail.com
+ naturalprogrammer.com
+ https://www.naturalprogrammer.com
+
+
+
+
+ scm:git:git://github.com/naturalprogrammer/spring-lemon.git
+ scm:git:git://github.com/naturalprogrammer/spring-lemon.git
+ https://github.com/naturalprogrammer/spring-lemon
+ HEAD
+
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 2.7.1
+
+
+
+
+ spring-lemon-exceptions
+ spring-lemon-commons
+ spring-lemon-commons-web
+ spring-lemon-commons-reactive
+ spring-lemon-commons-jpa
+ spring-lemon-commons-mongo
+ spring-lemon-jpa
+ spring-lemon-reactive
+ lemon-demo-jpa
+ lemon-demo-reactive
+
+
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.junit.vintage
+ junit-vintage-engine
+
+
+
+
+
+
+
+
+
+
+ org.sonatype.plugins
+ nexus-staging-maven-plugin
+ 1.6.8
+ true
+
+ ossrh
+ https://s01.oss.sonatype.org/
+ true
+
+
+
+
+
+
+
+ UTF-8
+ UTF-8
+ 1.8
+ 0.8.7
+
+
+
+
+
+
+ spring-snapshots
+ https://repo.spring.io/snapshot
+ true
+
+
+ spring-milestones
+ https://repo.spring.io/milestone
+
+
+
+
+ spring-snapshots
+ https://repo.spring.io/snapshot
+
+
+ spring-milestones
+ https://repo.spring.io/milestone
+
+
+
+
+
+ ossrh
+ https://s01.oss.sonatype.org/content/repositories/snapshots/
+
+
+
+
+
+ release
+
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.2.1
+
+
+ attach-sources
+
+ jar-no-fork
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ 3.2.0
+
+
+ attach-javadocs
+
+ jar
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 1.6
+
+
+ sign-artifacts
+ verify
+
+ sign
+
+
+
+
+
+
+
+
+
+
+
diff --git a/spring-lemon-commons-jpa/pom.xml b/spring-lemon-commons-jpa/pom.xml
new file mode 100644
index 00000000..a205789e
--- /dev/null
+++ b/spring-lemon-commons-jpa/pom.xml
@@ -0,0 +1,64 @@
+
+
+ 4.0.0
+
+ com.naturalprogrammer.spring-lemon
+ spring-lemon-commons-jpa
+ jar
+
+ ${project.groupId}:${project.artifactId}
+ Spring Lemon Commons JPA
+ https://github.com/naturalprogrammer/spring-lemon
+
+
+
+ Apache License, Version 2.0
+ https://www.apache.org/licenses/LICENSE-2.0.txt
+ repo
+
+
+
+
+
+ Sanjay Patel
+ skpatel20@gmail.com
+ naturalprogrammer.com
+ https://www.naturalprogrammer.com
+
+
+
+
+ scm:git:git://github.com/naturalprogrammer/spring-lemon.git
+ scm:git:git://github.com/naturalprogrammer/spring-lemon.git
+ https://github.com/naturalprogrammer/spring-lemon
+ HEAD
+
+
+
+ com.naturalprogrammer
+ spring-lemon
+ 1.0.2
+
+
+
+
+
+ com.naturalprogrammer.spring-lemon
+ spring-lemon-commons-web
+ ${project.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+ javax.xml.bind
+ jaxb-api
+
+
+
+
+
diff --git a/spring-lemon-commons-jpa/src/main/java/com/naturalprogrammer/spring/lemon/commonsjpa/LecjUtils.java b/spring-lemon-commons-jpa/src/main/java/com/naturalprogrammer/spring/lemon/commonsjpa/LecjUtils.java
new file mode 100644
index 00000000..93625c0a
--- /dev/null
+++ b/spring-lemon-commons-jpa/src/main/java/com/naturalprogrammer/spring/lemon/commonsjpa/LecjUtils.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsjpa;
+
+import com.naturalprogrammer.spring.lemon.exceptions.VersionException;
+import org.springframework.transaction.support.TransactionSynchronizationAdapter;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+public class LecjUtils {
+
+ /**
+ * A convenient method for running code
+ * after successful database commit.
+ *
+ * @param runnable
+ */
+ public static void afterCommit(Runnable runnable) {
+
+ TransactionSynchronizationManager.registerSynchronization(
+ new TransactionSynchronizationAdapter() {
+ @Override
+ public void afterCommit() {
+
+ runnable.run();
+ }
+ });
+ }
+
+
+ /**
+ * Throws a VersionException if the versions of the
+ * given entities aren't same.
+ *
+ * @param original
+ * @param updated
+ */
+ public static
+ void ensureCorrectVersion(LemonEntity original, LemonEntity updated) {
+
+ if (!Objects.equals(original.getVersion(), updated.getVersion()))
+ throw new VersionException(original.getClass().getSimpleName(), original.getId().toString());
+ }
+}
diff --git a/spring-lemon-commons-jpa/src/main/java/com/naturalprogrammer/spring/lemon/commonsjpa/LemonCommonsJpaAutoConfiguration.java b/spring-lemon-commons-jpa/src/main/java/com/naturalprogrammer/spring/lemon/commonsjpa/LemonCommonsJpaAutoConfiguration.java
new file mode 100644
index 00000000..a055fc66
--- /dev/null
+++ b/spring-lemon-commons-jpa/src/main/java/com/naturalprogrammer/spring/lemon/commonsjpa/LemonCommonsJpaAutoConfiguration.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsjpa;
+
+import com.naturalprogrammer.spring.lemon.commonsweb.LemonCommonsWebAutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+@Configuration
+@EnableTransactionManagement
+@EnableJpaAuditing
+@AutoConfigureBefore({LemonCommonsWebAutoConfiguration.class})
+public class LemonCommonsJpaAutoConfiguration {
+
+}
diff --git a/spring-lemon-commons-jpa/src/main/java/com/naturalprogrammer/spring/lemon/commonsjpa/LemonEntity.java b/spring-lemon-commons-jpa/src/main/java/com/naturalprogrammer/spring/lemon/commonsjpa/LemonEntity.java
new file mode 100644
index 00000000..41e638c3
--- /dev/null
+++ b/spring-lemon-commons-jpa/src/main/java/com/naturalprogrammer/spring/lemon/commonsjpa/LemonEntity.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsjpa;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.naturalprogrammer.spring.lemon.commons.security.PermissionEvaluatorEntity;
+import com.naturalprogrammer.spring.lemon.commons.security.UserDto;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.data.annotation.CreatedBy;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedBy;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.AbstractPersistable;
+
+import javax.persistence.MappedSuperclass;
+import javax.persistence.Temporal;
+import javax.persistence.TemporalType;
+import javax.persistence.Version;
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * Base class for all entities.
+ *
+ * @author Sanjay Patel
+ */
+@MappedSuperclass
+@Getter @Setter
+@JsonIgnoreProperties({ "createdById", "lastModifiedById", "createdDate", "lastModifiedDate", "new" })
+public class LemonEntity extends AbstractPersistable implements PermissionEvaluatorEntity {
+
+ private static final long serialVersionUID = -8151190931948396443L;
+
+ @CreatedBy
+ private ID createdById;
+
+ @CreatedDate
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date createdDate;
+
+ @LastModifiedBy
+ private ID lastModifiedById;
+
+ @LastModifiedDate
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date lastModifiedDate;
+
+ @Version
+ private Long version;
+
+ /**
+ * Whether the given user has the given permission for
+ * this entity. Override this method where you need.
+ */
+ @Override
+ public boolean hasPermission(UserDto user, String permission) {
+ return false;
+ }
+
+}
diff --git a/spring-lemon-jpa/src/main/resources/META-INF/orm.xml b/spring-lemon-commons-jpa/src/main/resources/META-INF/orm.xml
similarity index 100%
rename from spring-lemon-jpa/src/main/resources/META-INF/orm.xml
rename to spring-lemon-commons-jpa/src/main/resources/META-INF/orm.xml
diff --git a/spring-lemon-commons-jpa/src/main/resources/META-INF/spring.factories b/spring-lemon-commons-jpa/src/main/resources/META-INF/spring.factories
new file mode 100644
index 00000000..c4284f64
--- /dev/null
+++ b/spring-lemon-commons-jpa/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,2 @@
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+com.naturalprogrammer.spring.lemon.commonsjpa.LemonCommonsJpaAutoConfiguration
\ No newline at end of file
diff --git a/spring-lemon-commons-mongo/pom.xml b/spring-lemon-commons-mongo/pom.xml
new file mode 100644
index 00000000..6c5f2b9f
--- /dev/null
+++ b/spring-lemon-commons-mongo/pom.xml
@@ -0,0 +1,65 @@
+
+
+ 4.0.0
+
+ com.naturalprogrammer.spring-lemon
+ spring-lemon-commons-mongo
+ jar
+
+ ${project.groupId}:${project.artifactId}
+ Spring Lemon Commons MongoDB
+ https://github.com/naturalprogrammer/spring-lemon
+
+
+
+ Apache License, Version 2.0
+ https://www.apache.org/licenses/LICENSE-2.0.txt
+ repo
+
+
+
+
+
+ Sanjay Patel
+ skpatel20@gmail.com
+ naturalprogrammer.com
+ https://www.naturalprogrammer.com
+
+
+
+
+ scm:git:git://github.com/naturalprogrammer/spring-lemon.git
+ scm:git:git://github.com/naturalprogrammer/spring-lemon.git
+ https://github.com/naturalprogrammer/spring-lemon
+ HEAD
+
+
+
+ com.naturalprogrammer
+ spring-lemon
+ 1.0.2
+
+
+
+
+
+ com.naturalprogrammer.spring-lemon
+ spring-lemon-commons-reactive
+ ${project.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-mongodb-reactive
+
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+
+
+
diff --git a/spring-lemon-commons-mongo/src/main/java/com/naturalprogrammer/spring/lemon/commonsmongo/AbstractDocument.java b/spring-lemon-commons-mongo/src/main/java/com/naturalprogrammer/spring/lemon/commonsmongo/AbstractDocument.java
new file mode 100644
index 00000000..4b989291
--- /dev/null
+++ b/spring-lemon-commons-mongo/src/main/java/com/naturalprogrammer/spring/lemon/commonsmongo/AbstractDocument.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsmongo;
+
+import com.naturalprogrammer.spring.lemon.commons.security.PermissionEvaluatorEntity;
+import com.naturalprogrammer.spring.lemon.commons.security.UserDto;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.data.annotation.*;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Document
+@Getter @Setter
+public abstract class AbstractDocument implements PermissionEvaluatorEntity {
+
+ @Id
+ protected ID id;
+
+ @CreatedBy
+ private ID createdBy;
+
+ @CreatedDate
+ private Date createdDate;
+
+ @LastModifiedBy
+ private ID lastModifiedBy;
+
+ @LastModifiedDate
+ private Date lastModifiedDate;
+
+ @Version
+ private Long version;
+
+ /**
+ * Whether the given user has the given permission for
+ * this entity. Override this method where you need.
+ */
+ @Override
+ public boolean hasPermission(UserDto currentUser, String permission) {
+
+ return false;
+ }
+
+}
diff --git a/spring-lemon-commons-mongo/src/main/java/com/naturalprogrammer/spring/lemon/commonsmongo/LecmUtils.java b/spring-lemon-commons-mongo/src/main/java/com/naturalprogrammer/spring/lemon/commonsmongo/LecmUtils.java
new file mode 100644
index 00000000..c0843fd2
--- /dev/null
+++ b/spring-lemon-commons-mongo/src/main/java/com/naturalprogrammer/spring/lemon/commonsmongo/LecmUtils.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsmongo;
+
+import com.naturalprogrammer.spring.lemon.exceptions.VersionException;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+public class LecmUtils {
+
+ /**
+ * Throws a VersionException if the versions of the
+ * given entities aren't same.
+ *
+ * @param original
+ * @param updated
+ */
+ public static
+ void ensureCorrectVersion(AbstractDocument original, AbstractDocument updated) {
+
+ if (!Objects.equals(original.getVersion(), updated.getVersion()))
+ throw new VersionException(original.getClass().getSimpleName(), original.getId().toString());
+ }
+
+}
diff --git a/spring-lemon-commons-mongo/src/main/java/com/naturalprogrammer/spring/lemon/commonsmongo/LemonCommonsMongoAutoConfiguration.java b/spring-lemon-commons-mongo/src/main/java/com/naturalprogrammer/spring/lemon/commonsmongo/LemonCommonsMongoAutoConfiguration.java
new file mode 100644
index 00000000..2c1e730c
--- /dev/null
+++ b/spring-lemon-commons-mongo/src/main/java/com/naturalprogrammer/spring/lemon/commonsmongo/LemonCommonsMongoAutoConfiguration.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsmongo;
+
+import com.naturalprogrammer.spring.lemon.commonsreactive.LemonCommonsReactiveAutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.boot.autoconfigure.mongo.MongoReactiveAutoConfiguration;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.mongodb.config.EnableMongoAuditing;
+
+@Configuration
+@EnableMongoAuditing
+@AutoConfigureBefore({
+ MongoReactiveAutoConfiguration.class,
+ LemonCommonsReactiveAutoConfiguration.class})
+public class LemonCommonsMongoAutoConfiguration {
+
+}
diff --git a/spring-lemon-commons-mongo/src/main/resources/META-INF/spring.factories b/spring-lemon-commons-mongo/src/main/resources/META-INF/spring.factories
new file mode 100644
index 00000000..a567becb
--- /dev/null
+++ b/spring-lemon-commons-mongo/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,2 @@
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+com.naturalprogrammer.spring.lemon.commonsmongo.LemonCommonsMongoAutoConfiguration
\ No newline at end of file
diff --git a/spring-lemon-commons-reactive/pom.xml b/spring-lemon-commons-reactive/pom.xml
new file mode 100644
index 00000000..f0623243
--- /dev/null
+++ b/spring-lemon-commons-reactive/pom.xml
@@ -0,0 +1,76 @@
+
+
+ 4.0.0
+
+ com.naturalprogrammer.spring-lemon
+ spring-lemon-commons-reactive
+ jar
+
+ ${project.groupId}:${project.artifactId}
+ >Helper reactive commons library for Spring Boot REST APIs
+ https://github.com/naturalprogrammer/spring-lemon
+
+
+
+ Apache License, Version 2.0
+ https://www.apache.org/licenses/LICENSE-2.0.txt
+ repo
+
+
+
+
+
+ Sanjay Patel
+ skpatel20@gmail.com
+ naturalprogrammer.com
+ https://www.naturalprogrammer.com
+
+
+
+
+ scm:git:git://github.com/naturalprogrammer/spring-lemon.git
+ scm:git:git://github.com/naturalprogrammer/spring-lemon.git
+ https://github.com/naturalprogrammer/spring-lemon
+ HEAD
+
+
+
+ com.naturalprogrammer
+ spring-lemon
+ 1.0.2
+
+
+
+
+
+ com.naturalprogrammer.spring-lemon
+ spring-lemon-commons
+ ${project.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+
+ org.mongodb
+ bson
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+
+
+
diff --git a/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/LemonCommonsReactiveAutoConfiguration.java b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/LemonCommonsReactiveAutoConfiguration.java
new file mode 100644
index 00000000..ae230245
--- /dev/null
+++ b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/LemonCommonsReactiveAutoConfiguration.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsreactive;
+
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import com.naturalprogrammer.spring.lemon.commons.LemonCommonsAutoConfiguration;
+import com.naturalprogrammer.spring.lemon.commons.LemonProperties;
+import com.naturalprogrammer.spring.lemon.commons.security.BlueTokenService;
+import com.naturalprogrammer.spring.lemon.commonsreactive.exceptions.LemonReactiveErrorAttributes;
+import com.naturalprogrammer.spring.lemon.commonsreactive.exceptions.handlers.VersionExceptionHandler;
+import com.naturalprogrammer.spring.lemon.commonsreactive.security.LemonCommonsReactiveSecurityConfig;
+import com.naturalprogrammer.spring.lemon.commonsreactive.security.LemonCorsConfigurationSource;
+import com.naturalprogrammer.spring.lemon.commonsreactive.security.LemonReactiveAuditorAware;
+import com.naturalprogrammer.spring.lemon.commonsreactive.util.LecrUtils;
+import com.naturalprogrammer.spring.lemon.exceptions.ErrorResponseComposer;
+import com.naturalprogrammer.spring.lemon.exceptions.util.LexUtils;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.bson.types.ObjectId;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
+import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
+import org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration;
+import org.springframework.boot.web.reactive.error.ErrorAttributes;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.domain.AuditorAware;
+import org.springframework.security.access.PermissionEvaluator;
+import org.springframework.security.access.expression.AbstractSecurityExpressionHandler;
+import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.web.server.SecurityWebFilterChain;
+import org.springframework.web.cors.reactive.CorsConfigurationSource;
+
+import java.io.Serializable;
+
+@Configuration
+@EnableReactiveMethodSecurity
+@AutoConfigureBefore({
+ WebFluxAutoConfiguration.class,
+ ErrorWebFluxAutoConfiguration.class,
+ ReactiveSecurityAutoConfiguration.class,
+ LemonCommonsAutoConfiguration.class})
+@ComponentScan(basePackageClasses=VersionExceptionHandler.class)
+public class LemonCommonsReactiveAutoConfiguration {
+
+ private static final Log log = LogFactory.getLog(LemonCommonsReactiveAutoConfiguration.class);
+
+ public LemonCommonsReactiveAutoConfiguration() {
+ log.info("Created");
+ }
+
+
+ /**
+ * Configures an Error Attributes if missing
+ */
+ @Bean
+ @ConditionalOnMissingBean(ErrorAttributes.class)
+ public
+ ErrorAttributes errorAttributes(ErrorResponseComposer errorResponseComposer) {
+
+ log.info("Configuring LemonErrorAttributes");
+ return new LemonReactiveErrorAttributes(errorResponseComposer);
+ }
+
+
+ @Bean
+ @ConditionalOnMissingBean(LemonCommonsReactiveSecurityConfig.class)
+ public LemonCommonsReactiveSecurityConfig lemonReactiveSecurityConfig(BlueTokenService blueTokenService) {
+
+ log.info("Configuring LemonCommonsReactiveSecurityConfig ...");
+ return new LemonCommonsReactiveSecurityConfig(blueTokenService);
+ }
+
+
+ /**
+ * Configures SecurityWebFilterChain if missing
+ */
+ @Bean
+ public SecurityWebFilterChain springSecurityFilterChain(
+ ServerHttpSecurity http,
+ LemonCommonsReactiveSecurityConfig securityConfig,
+ AbstractSecurityExpressionHandler> expressionHandler,
+ PermissionEvaluator permissionEvaluator) {
+
+ log.info("Configuring SecurityWebFilterChain ...");
+ expressionHandler.setPermissionEvaluator(permissionEvaluator);
+ return securityConfig.springSecurityFilterChain(http);
+ }
+
+
+ /**
+ * Configures LemonCorsConfig if missing and lemon.cors.allowed-origins is provided
+ */
+ @Bean
+ @ConditionalOnProperty(name="lemon.cors.allowed-origins")
+ @ConditionalOnMissingBean(CorsConfigurationSource.class)
+ public LemonCorsConfigurationSource corsConfigurationSource(LemonProperties properties) {
+
+ log.info("Configuring LemonCorsConfigurationSource");
+ return new LemonCorsConfigurationSource(properties);
+ }
+
+
+ @Bean
+ public SimpleModule objectIdModule() {
+
+ SimpleModule module = new SimpleModule();
+ module.addSerializer(ObjectId.class, new ToStringSerializer());
+
+ return module;
+ }
+
+
+ /**
+ * Configures an Auditor Aware if missing
+ */
+ @Bean
+ @ConditionalOnMissingBean(AuditorAware.class)
+ public
+ AuditorAware auditorAware() {
+
+ log.info("Configuring LemonAuditorAware");
+ return new LemonReactiveAuditorAware();
+ }
+
+
+ /**
+ * Configures LeeUtils
+ */
+ @Bean
+ public LecrUtils lecrUtils(LexUtils lexUtils) {
+
+ log.info("Configuring LecrUtils");
+ return new LecrUtils();
+ }
+}
diff --git a/spring-lemon-reactive/src/main/java/com/naturalprogrammer/spring/lemonreactive/exceptions/LemonReactiveErrorAttributes.java b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/exceptions/LemonReactiveErrorAttributes.java
similarity index 60%
rename from spring-lemon-reactive/src/main/java/com/naturalprogrammer/spring/lemonreactive/exceptions/LemonReactiveErrorAttributes.java
rename to spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/exceptions/LemonReactiveErrorAttributes.java
index 57793866..ac23cf62 100644
--- a/spring-lemon-reactive/src/main/java/com/naturalprogrammer/spring/lemonreactive/exceptions/LemonReactiveErrorAttributes.java
+++ b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/exceptions/LemonReactiveErrorAttributes.java
@@ -1,13 +1,30 @@
-package com.naturalprogrammer.spring.lemonreactive.exceptions;
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
-import java.util.Map;
+package com.naturalprogrammer.spring.lemon.commonsreactive.exceptions;
+import com.naturalprogrammer.spring.lemon.exceptions.ErrorResponseComposer;
+import com.naturalprogrammer.spring.lemon.exceptions.util.LexUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
+import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.reactive.error.DefaultErrorAttributes;
import org.springframework.web.reactive.function.server.ServerRequest;
-import com.naturalprogrammer.spring.lemon.exceptions.ErrorResponseComposer;
+import java.util.Map;
public class LemonReactiveErrorAttributes extends DefaultErrorAttributes {
@@ -26,9 +43,9 @@ public LemonReactiveErrorAttributes(ErrorResponseComposer errorResponseCompos
@Override
public Map getErrorAttributes(ServerRequest request,
- boolean includeStackTrace) {
+ ErrorAttributeOptions options) {
- Map errorAttributes = super.getErrorAttributes(request, includeStackTrace);
+ Map errorAttributes = super.getErrorAttributes(request, options);
addLemonErrorDetails(errorAttributes, request);
return errorAttributes;
}
@@ -42,12 +59,13 @@ protected void addLemonErrorDetails(
Throwable ex = getError(request);
- errorAttributes.put("exception", ex.getClass().getSimpleName());
-
errorResponseComposer.compose((T)ex).ifPresent(errorResponse -> {
// check for nulls - errorResponse may have left something for the DefaultErrorAttributes
+ if (errorResponse.getExceptionId() != null)
+ errorAttributes.put("exceptionId", errorResponse.getExceptionId());
+
if (errorResponse.getMessage() != null)
errorAttributes.put("message", errorResponse.getMessage());
@@ -61,5 +79,8 @@ protected void addLemonErrorDetails(
if (errorResponse.getErrors() != null)
errorAttributes.put("errors", errorResponse.getErrors());
});
+
+ if (errorAttributes.get("exceptionId") == null)
+ errorAttributes.put("exceptionId", LexUtils.getExceptionId(ex));
}
}
diff --git a/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/exceptions/handlers/InsufficientAuthenticationExceptionHandler.java b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/exceptions/handlers/InsufficientAuthenticationExceptionHandler.java
new file mode 100644
index 00000000..4e98c085
--- /dev/null
+++ b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/exceptions/handlers/InsufficientAuthenticationExceptionHandler.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsreactive.exceptions.handlers;
+
+import com.naturalprogrammer.spring.lemon.exceptions.handlers.AbstractExceptionHandler;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.authentication.InsufficientAuthenticationException;
+import org.springframework.stereotype.Component;
+
+@Component
+@Order(Ordered.LOWEST_PRECEDENCE)
+public class InsufficientAuthenticationExceptionHandler extends AbstractExceptionHandler {
+
+ public InsufficientAuthenticationExceptionHandler() {
+ super(InsufficientAuthenticationException.class);
+ log.info("Created");
+ }
+
+ @Override
+ public HttpStatus getStatus(InsufficientAuthenticationException ex) {
+ return HttpStatus.FORBIDDEN;
+ }
+}
diff --git a/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/exceptions/handlers/VersionExceptionHandler.java b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/exceptions/handlers/VersionExceptionHandler.java
new file mode 100644
index 00000000..b596faf2
--- /dev/null
+++ b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/exceptions/handlers/VersionExceptionHandler.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsreactive.exceptions.handlers;
+
+import com.naturalprogrammer.spring.lemon.exceptions.VersionException;
+import com.naturalprogrammer.spring.lemon.exceptions.handlers.AbstractExceptionHandler;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+
+@Component
+@Order(Ordered.LOWEST_PRECEDENCE)
+public class VersionExceptionHandler extends AbstractExceptionHandler {
+
+ public VersionExceptionHandler() {
+
+ super(VersionException.class);
+ log.info("Created");
+ }
+
+ @Override
+ public HttpStatus getStatus(VersionException ex) {
+ return HttpStatus.CONFLICT;
+ }
+}
\ No newline at end of file
diff --git a/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/security/LemonCommonsReactiveSecurityConfig.java b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/security/LemonCommonsReactiveSecurityConfig.java
new file mode 100644
index 00000000..93783938
--- /dev/null
+++ b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/security/LemonCommonsReactiveSecurityConfig.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsreactive.security;
+
+import com.naturalprogrammer.spring.lemon.commons.security.BlueTokenService;
+import com.naturalprogrammer.spring.lemon.commons.security.LemonPrincipal;
+import com.naturalprogrammer.spring.lemon.commons.security.UserDto;
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemon.exceptions.util.LexUtils;
+import com.nimbusds.jwt.JWTClaimsSet;
+import lombok.AllArgsConstructor;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.web.server.SecurityWebFilterChain;
+import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
+import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
+import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository;
+import reactor.core.publisher.Mono;
+
+@AllArgsConstructor
+public class LemonCommonsReactiveSecurityConfig {
+
+ private static final Log log = LogFactory.getLog(LemonCommonsReactiveSecurityConfig.class);
+
+ protected BlueTokenService blueTokenService;
+
+ public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
+
+ log.info("Configuring SecurityWebFilterChain ...");
+
+ formLogin(http); // Configure form login
+ authorizeExchange(http); // configure authorization
+ oauth2Login(http); // configure OAuth2 login
+
+ return http
+ .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
+ .exceptionHandling()
+ .accessDeniedHandler((exchange, exception) -> Mono.error(exception))
+ .authenticationEntryPoint((exchange, exception) -> Mono.error(exception))
+ .and()
+ .cors()
+ .and()
+ .csrf().disable()
+ .addFilterAt(tokenAuthenticationFilter(), SecurityWebFiltersOrder.AUTHENTICATION)
+ .logout().disable()
+ .build();
+ }
+
+ /**
+ * Override this to configure oauth2 Login
+ */
+ protected void oauth2Login(ServerHttpSecurity http) {
+
+ // Bypass here. OAuth2 login is needed only in the auth service
+ }
+
+ /**
+ * Override this to configure authorization
+ */
+ protected void authorizeExchange(ServerHttpSecurity http) {
+
+ http.authorizeExchange()
+ .anyExchange().permitAll();
+ }
+
+
+ /**
+ * Configures form login
+ */
+ protected void formLogin(ServerHttpSecurity http) {
+
+ // Bypass here. Form login is needed only in the auth service
+ }
+
+
+ protected AuthenticationWebFilter tokenAuthenticationFilter() {
+
+ AuthenticationWebFilter filter = new AuthenticationWebFilter(tokenAuthenticationManager());
+ filter.setServerAuthenticationConverter(tokenAuthenticationConverter());
+ filter.setAuthenticationFailureHandler((exchange, exception) -> Mono.error(exception));
+
+ return filter;
+ }
+
+ protected ReactiveAuthenticationManager tokenAuthenticationManager() {
+
+ return authentication -> {
+
+ log.debug("Authenticating with token ...");
+
+ String token = (String) authentication.getCredentials();
+
+ JWTClaimsSet claims = blueTokenService.parseToken(token, BlueTokenService.AUTH_AUDIENCE);
+
+ UserDto userDto = LecUtils.getUserDto(claims);
+
+ Mono userDtoMono = userDto == null ?
+ fetchUserDto(claims) : Mono.just(userDto);
+
+ return userDtoMono.map(LemonPrincipal::new)
+ .doOnNext(LemonPrincipal::eraseCredentials)
+ .map(principal -> new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()));
+ };
+ }
+
+ /**
+ * Default behaviour is to throw error. To be overridden in auth service.
+ */
+ protected Mono fetchUserDto(JWTClaimsSet claims) {
+ return Mono.error(new AuthenticationCredentialsNotFoundException(
+ LexUtils.getMessage("com.naturalprogrammer.spring.userClaimAbsent")));
+ }
+
+ protected ServerAuthenticationConverter tokenAuthenticationConverter() {
+
+ return serverWebExchange -> {
+
+ String authorization = serverWebExchange.getRequest()
+ .getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
+
+ if(authorization == null || !authorization.startsWith(LecUtils.TOKEN_PREFIX))
+ return Mono.empty();
+
+ return Mono.just(new UsernamePasswordAuthenticationToken(null, authorization.substring(LecUtils.TOKEN_PREFIX_LENGTH)));
+ };
+ }
+}
diff --git a/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/security/LemonCorsConfigurationSource.java b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/security/LemonCorsConfigurationSource.java
new file mode 100644
index 00000000..1cb7fad7
--- /dev/null
+++ b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/security/LemonCorsConfigurationSource.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsreactive.security;
+
+import com.naturalprogrammer.spring.lemon.commons.LemonProperties;
+import com.naturalprogrammer.spring.lemon.commons.LemonProperties.Cors;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.reactive.CorsConfigurationSource;
+import org.springframework.web.server.ServerWebExchange;
+
+import java.util.Arrays;
+
+/**
+ * CORS Configuration
+ */
+public class LemonCorsConfigurationSource implements CorsConfigurationSource {
+
+ private static final Log log = LogFactory.getLog(LemonCorsConfigurationSource.class);
+
+ private Cors cors;
+
+ public LemonCorsConfigurationSource(LemonProperties properties) {
+
+ this.cors = properties.getCors();
+ log.info("Created");
+ }
+
+ @Override
+ public CorsConfiguration getCorsConfiguration(ServerWebExchange exchange) {
+
+ CorsConfiguration config = new CorsConfiguration();
+
+ config.setAllowCredentials(true);
+ config.setAllowedHeaders(Arrays.asList(cors.getAllowedHeaders()));
+ config.setAllowedMethods(Arrays.asList(cors.getAllowedMethods()));
+ config.setAllowedOrigins(Arrays.asList(cors.getAllowedOrigins()));
+ config.setExposedHeaders(Arrays.asList(cors.getExposedHeaders()));
+ config.setMaxAge(cors.getMaxAge());
+
+ return config;
+ }
+
+}
diff --git a/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/security/LemonReactiveAuditorAware.java b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/security/LemonReactiveAuditorAware.java
new file mode 100644
index 00000000..2999eee6
--- /dev/null
+++ b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/security/LemonReactiveAuditorAware.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsreactive.security;
+
+import com.naturalprogrammer.spring.lemon.commons.domain.AbstractAuditorAware;
+import com.naturalprogrammer.spring.lemon.commons.security.UserDto;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import java.io.Serializable;
+
+/**
+ * Needed for auto-filling of the
+ * AbstractAuditable columns of AbstractUser
+ *
+ * @author Sanjay Patel
+ */
+public class LemonReactiveAuditorAware
+extends AbstractAuditorAware {
+
+ private static final Log log = LogFactory.getLog(LemonReactiveAuditorAware.class);
+
+ public LemonReactiveAuditorAware() {
+ log.info("Created");
+ }
+
+ @Override
+ protected UserDto currentUser() {
+
+ // TODO: Can't return a mono, as below
+ // See this: https://jira.spring.io/browse/DATACMNS-1231
+ // So, sorry, no reactive auditing until Spring Data supports it
+ // But, if using MongoDB, you could implement a ReactiveBeforeConvertCallback:
+ // https://juliuskrah.com/blog/2018/02/15/auditing-with-spring-data-jpa/#comment-4848839807
+
+ // return LecrUtils.currentUser();
+ return null;
+ }
+}
diff --git a/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/util/LecrUtils.java b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/util/LecrUtils.java
new file mode 100644
index 00000000..79ee6353
--- /dev/null
+++ b/spring-lemon-commons-reactive/src/main/java/com/naturalprogrammer/spring/lemon/commonsreactive/util/LecrUtils.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020-2021 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this artifact or file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.naturalprogrammer.spring.lemon.commonsreactive.util;
+
+import com.github.fge.jsonpatch.JsonPatchException;
+import com.naturalprogrammer.spring.lemon.commons.security.UserDto;
+import com.naturalprogrammer.spring.lemon.commons.util.LecUtils;
+import com.naturalprogrammer.spring.lemon.exceptions.util.LexUtils;
+import org.springframework.http.HttpCookie;
+import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import javax.annotation.PostConstruct;
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.Optional;
+
+/**
+ * Useful helper methods
+ *
+ * @author Sanjay Patel
+ */
+public class LecrUtils {
+
+ private static Mono