This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch feat/forge-plugin-features
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 259bccdd511fafbc6c1cf0a3a19c54a18c80eae9
Author: James Daugherty <[email protected]>
AuthorDate: Sat Jul 18 14:20:26 2026 -0400

    Forge features for integrated plugins
---
 .../SpringResources.java => mail/Mail.java}        |  48 +++++---
 .../SpringResources.java => redis/Redis.java}      |  48 +++++---
 .../forge/feature/security/SpringSecurityCore.java | 126 +++++++++++++++++++++
 .../security/template/securityRole.rocker.raw      |  50 ++++++++
 .../security/template/securityUser.rocker.raw      |  79 +++++++++++++
 .../securityUserPasswordEncoderListener.rocker.raw |  66 +++++++++++
 .../security/template/securityUserRole.rocker.raw  | 113 ++++++++++++++++++
 .../template/springSecurityResources.rocker.raw    |  33 ++++++
 .../forge/feature/spring/SpringResources.java      |   6 +-
 .../org/grails/forge/feature/mail/MailSpec.groovy  |  57 ++++++++++
 .../grails/forge/feature/redis/RedisSpec.groovy    |  57 ++++++++++
 .../feature/security/SpringSecurityCoreSpec.groovy |  98 ++++++++++++++++
 12 files changed, 744 insertions(+), 37 deletions(-)

diff --git 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/spring/SpringResources.java
 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/mail/Mail.java
similarity index 57%
copy from 
grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/spring/SpringResources.java
copy to 
grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/mail/Mail.java
index 79eb828524..edde64563f 100644
--- 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/spring/SpringResources.java
+++ 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/mail/Mail.java
@@ -16,49 +16,61 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.forge.feature.spring;
+package org.grails.forge.feature.mail;
 
 import jakarta.inject.Singleton;
 import org.grails.forge.application.ApplicationType;
 import org.grails.forge.application.generator.GeneratorContext;
+import org.grails.forge.build.dependencies.Dependency;
 import org.grails.forge.feature.Category;
-import org.grails.forge.feature.DefaultFeature;
 import org.grails.forge.feature.Feature;
-import org.grails.forge.feature.spring.template.springResources;
-import org.grails.forge.options.Options;
-import org.grails.forge.template.RockerTemplate;
+import org.grails.forge.util.VersionInfo;
 
-import java.util.Set;
+import java.util.Map;
 
 @Singleton
-public class SpringResources implements DefaultFeature {
-    @Override
-    public boolean shouldApply(ApplicationType applicationType, Options 
options, Set<Feature> selectedFeatures) {
-        return true;
-    }
+public class Mail implements Feature {
 
     @Override
     public String getName() {
-        return "spring-resources";
+        return "mail";
     }
 
     @Override
-    public boolean supports(ApplicationType applicationType) {
-        return true;
+    public String getTitle() {
+        return "Mail";
     }
 
     @Override
-    public boolean isVisible() {
-        return false;
+    public String getDescription() {
+        return "The Mail Plugin provides a mailService and the sendMail 
builder for sending email, "
+                + "including GSP-rendered mail bodies.";
     }
 
     @Override
     public void apply(GeneratorContext generatorContext) {
-        generatorContext.addTemplate("springResources", new 
RockerTemplate("grails-app/conf/spring/resources.groovy", 
springResources.template()));
+        Map<String, Object> config = generatorContext.getConfiguration();
+        config.put("grails.mail.host", "localhost");
+        config.put("grails.mail.port", 25);
+        generatorContext.addDependency(Dependency.builder()
+                .groupId("org.apache.grails")
+                .artifactId("grails-mail")
+                .implementation());
+    }
+
+    @Override
+    public boolean supports(ApplicationType applicationType) {
+        return true;
     }
 
     @Override
     public String getCategory() {
-        return Category.SPRING;
+        return Category.MESSAGING;
     }
+
+    @Override
+    public String getDocumentation() {
+        return "https://grails.apache.org/docs/"; + 
VersionInfo.getDocumentationVersion() + "/guide/mail.html";
+    }
+
 }
diff --git 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/spring/SpringResources.java
 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/redis/Redis.java
similarity index 57%
copy from 
grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/spring/SpringResources.java
copy to 
grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/redis/Redis.java
index 79eb828524..f1cdc93d21 100644
--- 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/spring/SpringResources.java
+++ 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/redis/Redis.java
@@ -16,49 +16,61 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-package org.grails.forge.feature.spring;
+package org.grails.forge.feature.redis;
 
 import jakarta.inject.Singleton;
 import org.grails.forge.application.ApplicationType;
 import org.grails.forge.application.generator.GeneratorContext;
+import org.grails.forge.build.dependencies.Dependency;
 import org.grails.forge.feature.Category;
-import org.grails.forge.feature.DefaultFeature;
 import org.grails.forge.feature.Feature;
-import org.grails.forge.feature.spring.template.springResources;
-import org.grails.forge.options.Options;
-import org.grails.forge.template.RockerTemplate;
+import org.grails.forge.util.VersionInfo;
 
-import java.util.Set;
+import java.util.Map;
 
 @Singleton
-public class SpringResources implements DefaultFeature {
-    @Override
-    public boolean shouldApply(ApplicationType applicationType, Options 
options, Set<Feature> selectedFeatures) {
-        return true;
-    }
+public class Redis implements Feature {
 
     @Override
     public String getName() {
-        return "spring-resources";
+        return "redis";
     }
 
     @Override
-    public boolean supports(ApplicationType applicationType) {
-        return true;
+    public String getTitle() {
+        return "Redis";
     }
 
     @Override
-    public boolean isVisible() {
-        return false;
+    public String getDescription() {
+        return "The Redis Plugin provides a redisService and access to Jedis 
connection pools "
+                + "for working with a Redis data store.";
     }
 
     @Override
     public void apply(GeneratorContext generatorContext) {
-        generatorContext.addTemplate("springResources", new 
RockerTemplate("grails-app/conf/spring/resources.groovy", 
springResources.template()));
+        Map<String, Object> config = generatorContext.getConfiguration();
+        config.put("grails.redis.host", "localhost");
+        config.put("grails.redis.port", 6379);
+        generatorContext.addDependency(Dependency.builder()
+                .groupId("org.apache.grails")
+                .artifactId("grails-redis")
+                .implementation());
+    }
+
+    @Override
+    public boolean supports(ApplicationType applicationType) {
+        return true;
     }
 
     @Override
     public String getCategory() {
-        return Category.SPRING;
+        return Category.DATABASE;
     }
+
+    @Override
+    public String getDocumentation() {
+        return "https://grails.apache.org/docs/"; + 
VersionInfo.getDocumentationVersion() + "/guide/redis.html";
+    }
+
 }
diff --git 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/SpringSecurityCore.java
 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/SpringSecurityCore.java
new file mode 100644
index 0000000000..f96fadfc6b
--- /dev/null
+++ 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/SpringSecurityCore.java
@@ -0,0 +1,126 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this 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 org.grails.forge.feature.security;
+
+import jakarta.inject.Singleton;
+import org.grails.forge.application.ApplicationType;
+import org.grails.forge.application.Project;
+import org.grails.forge.application.generator.GeneratorContext;
+import org.grails.forge.build.dependencies.Dependency;
+import org.grails.forge.feature.Category;
+import org.grails.forge.feature.Feature;
+import org.grails.forge.feature.security.template.securityRole;
+import org.grails.forge.feature.security.template.springSecurityResources;
+import org.grails.forge.feature.security.template.securityUser;
+import 
org.grails.forge.feature.security.template.securityUserPasswordEncoderListener;
+import org.grails.forge.feature.security.template.securityUserRole;
+import org.grails.forge.template.RockerTemplate;
+import org.grails.forge.util.VersionInfo;
+
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class SpringSecurityCore implements Feature {
+
+    @Override
+    public String getName() {
+        return "spring-security";
+    }
+
+    @Override
+    public String getTitle() {
+        return "Spring Security Core";
+    }
+
+    @Override
+    public String getDescription() {
+        return "Secures the application with the Spring Security Core plugin, 
generating the SecurityUser, SecurityRole and "
+                + "SecurityUserRole domain classes and the standard permit-all 
rules for the public pages "
+                + "(the same starting point the s2-quickstart command 
produces).";
+    }
+
+    @Override
+    public void apply(GeneratorContext generatorContext) {
+        Project project = generatorContext.getProject();
+        String packageName = project.getPackageName();
+
+        Map<String, Object> config = generatorContext.getConfiguration();
+        
config.put("grails.plugin.springsecurity.userLookup.userDomainClassName", 
packageName + ".SecurityUser");
+        
config.put("grails.plugin.springsecurity.userLookup.authorityJoinClassName", 
packageName + ".SecurityUserRole");
+        config.put("grails.plugin.springsecurity.authority.className", 
packageName + ".SecurityRole");
+        
config.put("grails.plugin.springsecurity.controllerAnnotations.staticRules", 
staticRules());
+
+        generatorContext.addDependency(Dependency.builder()
+                .groupId("org.apache.grails")
+                .artifactId("grails-spring-security")
+                .implementation());
+
+        generatorContext.addTemplate("springSecurityUserDomain",
+                new 
RockerTemplate("grails-app/domain/{packagePath}/SecurityUser.groovy", 
securityUser.template(project)));
+        generatorContext.addTemplate("springSecurityRoleDomain",
+                new 
RockerTemplate("grails-app/domain/{packagePath}/SecurityRole.groovy", 
securityRole.template(project)));
+        generatorContext.addTemplate("springSecurityUserRoleDomain",
+                new 
RockerTemplate("grails-app/domain/{packagePath}/SecurityUserRole.groovy", 
securityUserRole.template(project)));
+
+        // GORM does not autowire services into domain instances by default, 
so password encoding is
+        // performed by a persistence-event listener bean — the same approach 
s2-quickstart generates.
+        generatorContext.addTemplate("springSecurityPasswordEncoderListener",
+                new 
RockerTemplate("src/main/groovy/{packagePath}/SecurityUserPasswordEncoderListener.groovy",
+                        
securityUserPasswordEncoderListener.template(project)));
+        // SpringResources backs off when this feature is selected, so this is 
the only
+        // template writing grails-app/conf/spring/resources.groovy
+        generatorContext.addTemplate("springResources",
+                new RockerTemplate("grails-app/conf/spring/resources.groovy", 
springSecurityResources.template(project)));
+    }
+
+    private static List<Map<String, Object>> staticRules() {
+        return List.of(
+                rule("/", "permitAll"),
+                rule("/error", "permitAll"),
+                rule("/index", "permitAll"),
+                rule("/index.gsp", "permitAll"),
+                rule("/shutdown", "permitAll"),
+                rule("/assets/**", "permitAll"),
+                rule("/**/js/**", "permitAll"),
+                rule("/**/css/**", "permitAll"),
+                rule("/**/images/**", "permitAll"),
+                rule("/**/favicon.ico", "permitAll"));
+    }
+
+    private static Map<String, Object> rule(String pattern, String access) {
+        return Map.of("pattern", pattern, "access", List.of(access));
+    }
+
+    @Override
+    public boolean supports(ApplicationType applicationType) {
+        return applicationType == ApplicationType.WEB || applicationType == 
ApplicationType.REST_API;
+    }
+
+    @Override
+    public String getCategory() {
+        return Category.SECURITY;
+    }
+
+    @Override
+    public String getDocumentation() {
+        return "https://grails.apache.org/docs/"; + 
VersionInfo.getDocumentationVersion() + "/guide/security.html";
+    }
+
+}
diff --git 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityRole.rocker.raw
 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityRole.rocker.raw
new file mode 100644
index 0000000000..f8ae968d11
--- /dev/null
+++ 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityRole.rocker.raw
@@ -0,0 +1,50 @@
+@*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this 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 org.grails.forge.application.Project
+
+@args (
+Project project
+)
+
+@if (project.getPackageName() != null) {
+package @project.getPackageName()
+
+}
+import grails.compiler.GrailsCompileStatic
+import groovy.transform.EqualsAndHashCode
+import groovy.transform.ToString
+
+@@GrailsCompileStatic
+@@EqualsAndHashCode(includes = 'authority')
+@@ToString(includes = 'authority', includeNames = true, includePackage = false)
+class SecurityRole implements Serializable {
+
+    private static final long serialVersionUID = 1
+
+    String authority
+
+    static constraints = {
+        authority nullable: false, blank: false, unique: true
+    }
+
+    static mapping = {
+        cache true
+    }
+}
diff --git 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityUser.rocker.raw
 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityUser.rocker.raw
new file mode 100644
index 0000000000..13efdf7c63
--- /dev/null
+++ 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityUser.rocker.raw
@@ -0,0 +1,79 @@
+@*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this 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 org.grails.forge.application.Project
+
+@args (
+Project project
+)
+
+@if (project.getPackageName() != null) {
+package @project.getPackageName()
+
+}
+import grails.compiler.GrailsCompileStatic
+import grails.plugin.springsecurity.SpringSecurityService
+import groovy.transform.EqualsAndHashCode
+import groovy.transform.ToString
+
+@@GrailsCompileStatic
+@@EqualsAndHashCode(includes = 'username')
+@@ToString(includes = 'username', includeNames = true, includePackage = false)
+class SecurityUser implements Serializable {
+
+    private static final long serialVersionUID = 1
+
+    SpringSecurityService springSecurityService
+
+    String username
+    String password
+    boolean enabled = true
+    boolean accountExpired
+    boolean accountLocked
+    boolean passwordExpired
+
+    Set<SecurityRole> getAuthorities() {
+        (SecurityUserRole.findAllBySecurityUser(this) as 
List<SecurityUserRole>)*.securityRole as Set<SecurityRole>
+    }
+
+    def beforeInsert() {
+        encodePassword()
+    }
+
+    def beforeUpdate() {
+        if (isDirty('password')) {
+            encodePassword()
+        }
+    }
+
+    protected void encodePassword() {
+        password = springSecurityService?.passwordEncoder ? 
springSecurityService.encodePassword(password) : password
+    }
+
+    static transients = ['springSecurityService']
+
+    static constraints = {
+        password nullable: false, blank: false, password: true
+        username nullable: false, blank: false, unique: true
+    }
+
+    static mapping = {
+        password column: '`password`'
+    }
+}
diff --git 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityUserPasswordEncoderListener.rocker.raw
 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityUserPasswordEncoderListener.rocker.raw
new file mode 100644
index 0000000000..aafae39e58
--- /dev/null
+++ 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityUserPasswordEncoderListener.rocker.raw
@@ -0,0 +1,66 @@
+@*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this 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 org.grails.forge.application.Project
+
+@args (
+Project project
+)
+
+@if (project.getPackageName() != null) {
+package @project.getPackageName()
+
+}
+import grails.events.annotation.gorm.Listener
+import grails.plugin.springsecurity.SpringSecurityService
+import groovy.transform.CompileStatic
+import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
+import org.grails.datastore.mapping.engine.event.PreInsertEvent
+import org.grails.datastore.mapping.engine.event.PreUpdateEvent
+import org.springframework.beans.factory.annotation.Autowired
+
+@@CompileStatic
+class SecurityUserPasswordEncoderListener {
+
+    @@Autowired
+    SpringSecurityService springSecurityService
+
+    @@Listener(SecurityUser)
+    void onPreInsertEvent(PreInsertEvent event) {
+        encodePasswordForEvent(event)
+    }
+
+    @@Listener(SecurityUser)
+    void onPreUpdateEvent(PreUpdateEvent event) {
+        encodePasswordForEvent(event)
+    }
+
+    private void encodePasswordForEvent(AbstractPersistenceEvent event) {
+        if (event.entityObject instanceof SecurityUser) {
+            SecurityUser u = event.entityObject as SecurityUser
+            if (u.password && ((event instanceof PreInsertEvent) || (event 
instanceof PreUpdateEvent && u.isDirty('password')))) {
+                event.getEntityAccess().setProperty('password', 
encodePassword(u.password))
+            }
+        }
+    }
+
+    private String encodePassword(String password) {
+        springSecurityService?.passwordEncoder ? 
springSecurityService.encodePassword(password) : password
+    }
+}
diff --git 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityUserRole.rocker.raw
 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityUserRole.rocker.raw
new file mode 100644
index 0000000000..c366af2cd2
--- /dev/null
+++ 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/securityUserRole.rocker.raw
@@ -0,0 +1,113 @@
+@*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this 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 org.grails.forge.application.Project
+
+@args (
+Project project
+)
+
+@if (project.getPackageName() != null) {
+package @project.getPackageName()
+
+}
+import grails.compiler.GrailsCompileStatic
+import grails.gorm.DetachedCriteria
+import groovy.transform.ToString
+import org.codehaus.groovy.util.HashCodeHelper
+
+@@GrailsCompileStatic
+@@ToString(cache = true, includeNames = true, includePackage = false)
+class SecurityUserRole implements Serializable {
+
+    private static final long serialVersionUID = 1
+
+    SecurityUser securityUser
+    SecurityRole securityRole
+
+    @@Override
+    boolean equals(other) {
+        if (other instanceof SecurityUserRole) {
+            other.securityUserId == securityUser?.id && other.securityRoleId 
== securityRole?.id
+        }
+    }
+
+    @@Override
+    int hashCode() {
+        int hashCode = HashCodeHelper.initHash()
+        if (securityUser) {
+            hashCode = HashCodeHelper.updateHash(hashCode, securityUser.id)
+        }
+        if (securityRole) {
+            hashCode = HashCodeHelper.updateHash(hashCode, securityRole.id)
+        }
+        hashCode
+    }
+
+    static SecurityUserRole get(long userId, long roleId) {
+        criteriaFor(userId, roleId).get()
+    }
+
+    static boolean exists(long userId, long roleId) {
+        criteriaFor(userId, roleId).count()
+    }
+
+    private static DetachedCriteria<SecurityUserRole> criteriaFor(long userId, 
long roleId) {
+        SecurityUserRole.where {
+            securityUser == SecurityUser.load(userId) &&
+            securityRole == SecurityRole.load(roleId)
+        }
+    }
+
+    static SecurityUserRole create(SecurityUser user, SecurityRole role, 
boolean flush = false) {
+        def instance = new SecurityUserRole(securityUser: user, securityRole: 
role)
+        instance.save(flush: flush)
+        instance
+    }
+
+    static boolean remove(SecurityUser u, SecurityRole r) {
+        if (u != null && r != null) {
+            SecurityUserRole.where { securityUser == u && securityRole == r 
}.deleteAll()
+        }
+    }
+
+    static int removeAll(SecurityUser u) {
+        u == null ? 0 : SecurityUserRole.where { securityUser == u 
}.deleteAll() as int
+    }
+
+    static int removeAll(SecurityRole r) {
+        r == null ? 0 : SecurityUserRole.where { securityRole == r 
}.deleteAll() as int
+    }
+
+    static constraints = {
+        securityUser nullable: false
+        securityRole nullable: false, validator: { SecurityRole r, 
SecurityUserRole ur ->
+            if (ur.securityUser?.id) {
+                if (SecurityUserRole.exists(ur.securityUser.id, r.id)) {
+                    return ['userRole.exists']
+                }
+            }
+        }
+    }
+
+    static mapping = {
+        id composite: ['securityUser', 'securityRole']
+        version false
+    }
+}
diff --git 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/springSecurityResources.rocker.raw
 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/springSecurityResources.rocker.raw
new file mode 100644
index 0000000000..49a2072220
--- /dev/null
+++ 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/security/template/springSecurityResources.rocker.raw
@@ -0,0 +1,33 @@
+@*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this 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 org.grails.forge.application.Project
+
+@args (
+Project project
+)
+
+@if (project.getPackageName() != null) {
+import @(project.getPackageName()).SecurityUserPasswordEncoderListener
+
+}
+// Place your Spring DSL code here
+beans = {
+    securityUserPasswordEncoderListener(SecurityUserPasswordEncoderListener)
+}
diff --git 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/spring/SpringResources.java
 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/spring/SpringResources.java
index 79eb828524..fe2f337290 100644
--- 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/spring/SpringResources.java
+++ 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/spring/SpringResources.java
@@ -28,13 +28,17 @@ import 
org.grails.forge.feature.spring.template.springResources;
 import org.grails.forge.options.Options;
 import org.grails.forge.template.RockerTemplate;
 
+import org.grails.forge.feature.security.SpringSecurityCore;
+
 import java.util.Set;
 
 @Singleton
 public class SpringResources implements DefaultFeature {
     @Override
     public boolean shouldApply(ApplicationType applicationType, Options 
options, Set<Feature> selectedFeatures) {
-        return true;
+        // The spring-security feature contributes its own resources.groovy 
registering the
+        // password-encoder listener bean, so the default empty one must back 
off
+        return 
selectedFeatures.stream().noneMatch(SpringSecurityCore.class::isInstance);
     }
 
     @Override
diff --git 
a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/mail/MailSpec.groovy
 
b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/mail/MailSpec.groovy
new file mode 100644
index 0000000000..63ef25cc8b
--- /dev/null
+++ 
b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/mail/MailSpec.groovy
@@ -0,0 +1,57 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this 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 org.grails.forge.feature.mail
+
+import org.grails.forge.ApplicationContextSpec
+import org.grails.forge.BuildBuilder
+import org.grails.forge.application.generator.GeneratorContext
+import org.grails.forge.fixture.CommandOutputFixture
+
+class MailSpec extends ApplicationContextSpec implements CommandOutputFixture {
+
+    void 'test gradle mail feature adds the plugin dependency'() {
+        when:
+        String template = new BuildBuilder(beanContext)
+                .features(['mail'])
+                .render()
+
+        then:
+        template.contains('implementation "org.apache.grails:grails-mail"')
+    }
+
+    void 'test mail feature configures the smtp defaults'() {
+        when:
+        GeneratorContext commandContext = buildGeneratorContext(['mail'])
+
+        then:
+        commandContext.configuration.get('grails.mail.host') == 'localhost'
+        commandContext.configuration.get('grails.mail.port') == 25
+    }
+
+    void 'test readme.md with feature mail contains links to documentation'() {
+        when:
+        def output = generate(['mail'])
+        def readme = output['README.md']
+
+        then:
+        readme
+        readme.contains('guide/mail.html')
+    }
+
+}
diff --git 
a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/redis/RedisSpec.groovy
 
b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/redis/RedisSpec.groovy
new file mode 100644
index 0000000000..859e6fb5d0
--- /dev/null
+++ 
b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/redis/RedisSpec.groovy
@@ -0,0 +1,57 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this 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 org.grails.forge.feature.redis
+
+import org.grails.forge.ApplicationContextSpec
+import org.grails.forge.BuildBuilder
+import org.grails.forge.application.generator.GeneratorContext
+import org.grails.forge.fixture.CommandOutputFixture
+
+class RedisSpec extends ApplicationContextSpec implements CommandOutputFixture 
{
+
+    void 'test gradle redis feature adds the plugin dependency'() {
+        when:
+        String template = new BuildBuilder(beanContext)
+                .features(['redis'])
+                .render()
+
+        then:
+        template.contains('implementation "org.apache.grails:grails-redis"')
+    }
+
+    void 'test redis feature configures the connection defaults'() {
+        when:
+        GeneratorContext commandContext = buildGeneratorContext(['redis'])
+
+        then:
+        commandContext.configuration.get('grails.redis.host') == 'localhost'
+        commandContext.configuration.get('grails.redis.port') == 6379
+    }
+
+    void 'test readme.md with feature redis contains links to documentation'() 
{
+        when:
+        def output = generate(['redis'])
+        def readme = output['README.md']
+
+        then:
+        readme
+        readme.contains('guide/redis.html')
+    }
+
+}
diff --git 
a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/security/SpringSecurityCoreSpec.groovy
 
b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/security/SpringSecurityCoreSpec.groovy
new file mode 100644
index 0000000000..4e129938f7
--- /dev/null
+++ 
b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/security/SpringSecurityCoreSpec.groovy
@@ -0,0 +1,98 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this 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 org.grails.forge.feature.security
+
+import org.grails.forge.ApplicationContextSpec
+import org.grails.forge.BuildBuilder
+import org.grails.forge.application.generator.GeneratorContext
+import org.grails.forge.fixture.CommandOutputFixture
+
+class SpringSecurityCoreSpec extends ApplicationContextSpec implements 
CommandOutputFixture {
+
+    void 'test gradle spring-security feature adds the plugin dependency'() {
+        when:
+        String template = new BuildBuilder(beanContext)
+                .features(['spring-security'])
+                .render()
+
+        then:
+        template.contains('implementation 
"org.apache.grails:grails-spring-security"')
+    }
+
+    void 'test spring-security feature configures the domain class lookups'() {
+        when:
+        GeneratorContext commandContext = 
buildGeneratorContext(['spring-security'])
+
+        then:
+        
commandContext.configuration.get('grails.plugin.springsecurity.userLookup.userDomainClassName')
 == 'example.grails.SecurityUser'
+        
commandContext.configuration.get('grails.plugin.springsecurity.userLookup.authorityJoinClassName')
 == 'example.grails.SecurityUserRole'
+        
commandContext.configuration.get('grails.plugin.springsecurity.authority.className')
 == 'example.grails.SecurityRole'
+
+        and: 'the standard permit-all static rules are configured'
+        List<Map<String, Object>> staticRules = (List<Map<String, Object>>) 
commandContext.configuration
+                
.get('grails.plugin.springsecurity.controllerAnnotations.staticRules')
+        staticRules.find { it.pattern == '/' }.access == ['permitAll']
+        staticRules.find { it.pattern == '/assets/**' }.access == ['permitAll']
+    }
+
+    void 'test spring-security feature generates the security domain 
classes'() {
+        when:
+        def output = generate(['spring-security'])
+        def user = 
output['grails-app/domain/example/grails/SecurityUser.groovy']
+        def role = 
output['grails-app/domain/example/grails/SecurityRole.groovy']
+        def userRole = 
output['grails-app/domain/example/grails/SecurityUserRole.groovy']
+
+        then:
+        user
+        user.contains('package example.grails')
+        user.contains('class SecurityUser implements Serializable')
+        user.contains('Set<SecurityRole> getAuthorities()')
+        user.contains('protected void encodePassword()')
+
+        and:
+        role
+        role.contains('class SecurityRole implements Serializable')
+        role.contains('String authority')
+
+        and:
+        userRole
+        userRole.contains('class SecurityUserRole implements Serializable')
+        userRole.contains("id composite: ['securityUser', 'securityRole']")
+
+        and: 'the password encoder listener is generated and registered as a 
bean'
+        def listener = 
output['src/main/groovy/example/grails/SecurityUserPasswordEncoderListener.groovy']
+        listener
+        listener.contains('class SecurityUserPasswordEncoderListener')
+        listener.contains('@Listener(SecurityUser)')
+        def resources = output['grails-app/conf/spring/resources.groovy']
+        resources.contains('import 
example.grails.SecurityUserPasswordEncoderListener')
+        
resources.contains('securityUserPasswordEncoderListener(SecurityUserPasswordEncoderListener)')
+    }
+
+    void 'test readme.md with feature spring-security contains links to 
documentation'() {
+        when:
+        def output = generate(['spring-security'])
+        def readme = output['README.md']
+
+        then:
+        readme
+        readme.contains('guide/security.html')
+    }
+
+}


Reply via email to