View Javadoc
1   /*
2    * Copyright (C) 2023 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.configuration.dev;
7   
8   import static org.tailormap.api.persistence.Configuration.HOME_PAGE;
9   import static org.tailormap.api.persistence.Configuration.PORTAL_MENU;
10  import static org.tailormap.api.persistence.json.GeoServiceProtocol.QUANTIZEDMESH;
11  import static org.tailormap.api.persistence.json.GeoServiceProtocol.TILES3D;
12  import static org.tailormap.api.persistence.json.GeoServiceProtocol.WMS;
13  import static org.tailormap.api.persistence.json.GeoServiceProtocol.WMTS;
14  import static org.tailormap.api.persistence.json.GeoServiceProtocol.XYZ;
15  import static org.tailormap.api.persistence.json.HiddenLayerFunctionalityEnum.ATTRIBUTE_LIST;
16  import static org.tailormap.api.persistence.json.HiddenLayerFunctionalityEnum.EXPORT;
17  import static org.tailormap.api.persistence.json.HiddenLayerFunctionalityEnum.FEATURE_INFO;
18  import static org.tailormap.api.security.AuthorisationService.ACCESS_TYPE_VIEW;
19  
20  import java.io.IOException;
21  import java.lang.invoke.MethodHandles;
22  import java.net.URI;
23  import java.nio.charset.StandardCharsets;
24  import java.sql.SQLException;
25  import java.time.OffsetDateTime;
26  import java.time.ZoneId;
27  import java.util.ArrayList;
28  import java.util.Collection;
29  import java.util.List;
30  import java.util.Map;
31  import java.util.NoSuchElementException;
32  import java.util.Optional;
33  import java.util.Properties;
34  import java.util.Set;
35  import java.util.UUID;
36  import org.apache.solr.client.solrj.SolrServerException;
37  import org.quartz.SchedulerException;
38  import org.slf4j.Logger;
39  import org.slf4j.LoggerFactory;
40  import org.springframework.beans.factory.annotation.Value;
41  import org.springframework.boot.SpringApplication;
42  import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
43  import org.springframework.boot.context.event.ApplicationReadyEvent;
44  import org.springframework.context.ApplicationContext;
45  import org.springframework.context.event.EventListener;
46  import org.springframework.core.io.ClassPathResource;
47  import org.springframework.jdbc.core.simple.JdbcClient;
48  import org.springframework.transaction.annotation.Transactional;
49  import org.springframework.util.PropertyPlaceholderHelper;
50  import org.tailormap.api.admin.model.CapabilitiesLoadingEvent;
51  import org.tailormap.api.admin.model.TaskSchedule;
52  import org.tailormap.api.geotools.featuresources.AttachmentsHelper;
53  import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
54  import org.tailormap.api.geotools.featuresources.JDBCFeatureSourceHelper;
55  import org.tailormap.api.geotools.featuresources.WFSFeatureSourceHelper;
56  import org.tailormap.api.persistence.Application;
57  import org.tailormap.api.persistence.Catalog;
58  import org.tailormap.api.persistence.Configuration;
59  import org.tailormap.api.persistence.GeoService;
60  import org.tailormap.api.persistence.Group;
61  import org.tailormap.api.persistence.Page;
62  import org.tailormap.api.persistence.SearchIndex;
63  import org.tailormap.api.persistence.TMFeatureSource;
64  import org.tailormap.api.persistence.TMFeatureType;
65  import org.tailormap.api.persistence.Upload;
66  import org.tailormap.api.persistence.UploadCategory;
67  import org.tailormap.api.persistence.User;
68  import org.tailormap.api.persistence.helper.CapabilitiesLoadingProgressReporting;
69  import org.tailormap.api.persistence.helper.GeoServiceHelper;
70  import org.tailormap.api.persistence.json.AppContent;
71  import org.tailormap.api.persistence.json.AppLayerSettings;
72  import org.tailormap.api.persistence.json.AppSettings;
73  import org.tailormap.api.persistence.json.AppTreeLayerNode;
74  import org.tailormap.api.persistence.json.AppTreeLevelNode;
75  import org.tailormap.api.persistence.json.AppTreeNode;
76  import org.tailormap.api.persistence.json.AppUiSettings;
77  import org.tailormap.api.persistence.json.AttachmentAttributeType;
78  import org.tailormap.api.persistence.json.AttributeSettings;
79  import org.tailormap.api.persistence.json.AttributeValueSettings;
80  import org.tailormap.api.persistence.json.AuthorizationRule;
81  import org.tailormap.api.persistence.json.AuthorizationRuleDecision;
82  import org.tailormap.api.persistence.json.Bounds;
83  import org.tailormap.api.persistence.json.CatalogNode;
84  import org.tailormap.api.persistence.json.FeatureTypeRef;
85  import org.tailormap.api.persistence.json.FeatureTypeTemplate;
86  import org.tailormap.api.persistence.json.Filter;
87  import org.tailormap.api.persistence.json.FilterEditConfiguration;
88  import org.tailormap.api.persistence.json.FilterGroup;
89  import org.tailormap.api.persistence.json.GeoServiceDefaultLayerSettings;
90  import org.tailormap.api.persistence.json.GeoServiceLayerSettings;
91  import org.tailormap.api.persistence.json.GeoServiceSettings;
92  import org.tailormap.api.persistence.json.JDBCConnectionProperties;
93  import org.tailormap.api.persistence.json.MenuItem;
94  import org.tailormap.api.persistence.json.PageTile;
95  import org.tailormap.api.persistence.json.ServiceAuthentication;
96  import org.tailormap.api.persistence.json.TailormapObjectRef;
97  import org.tailormap.api.persistence.json.TileLayerHiDpiMode;
98  import org.tailormap.api.persistence.json.WMSStyle;
99  import org.tailormap.api.repository.ApplicationRepository;
100 import org.tailormap.api.repository.CatalogRepository;
101 import org.tailormap.api.repository.ConfigurationRepository;
102 import org.tailormap.api.repository.FeatureSourceRepository;
103 import org.tailormap.api.repository.GeoServiceRepository;
104 import org.tailormap.api.repository.GroupRepository;
105 import org.tailormap.api.repository.PageRepository;
106 import org.tailormap.api.repository.SearchIndexRepository;
107 import org.tailormap.api.repository.UploadRepository;
108 import org.tailormap.api.repository.UserRepository;
109 import org.tailormap.api.scheduling.IndexTask;
110 import org.tailormap.api.scheduling.TMJobDataMap;
111 import org.tailormap.api.scheduling.Task;
112 import org.tailormap.api.scheduling.TaskManagerService;
113 import org.tailormap.api.scheduling.TaskType;
114 import org.tailormap.api.security.InternalAdminAuthentication;
115 import org.tailormap.api.service.UploadsService;
116 import org.tailormap.api.solr.SolrHelper;
117 import org.tailormap.api.solr.SolrService;
118 import org.tailormap.api.viewer.model.AppStyling;
119 import org.tailormap.api.viewer.model.Component;
120 import org.tailormap.api.viewer.model.ComponentConfig;
121 import tools.jackson.core.JacksonException;
122 import tools.jackson.databind.json.JsonMapper;
123 
124 /**
125  * Populates entities to add services and applications to demo functionality, support development and use in integration
126  * tests with a common set of test data. See README.md for usage details.
127  */
128 @org.springframework.context.annotation.Configuration
129 @ConditionalOnProperty(name = "tailormap-api.database.populate-testdata", havingValue = "true")
130 public class PopulateTestData {
131 
132   private static final Logger logger =
133       LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
134   private final ApplicationContext appContext;
135   private final UserRepository userRepository;
136   private final GroupRepository groupRepository;
137   private final CatalogRepository catalogRepository;
138   private final GeoServiceRepository geoServiceRepository;
139   private final GeoServiceHelper geoServiceHelper;
140   private final SolrService solrService;
141   private final TaskManagerService taskManagerService;
142   private final FeatureSourceRepository featureSourceRepository;
143   private final ApplicationRepository applicationRepository;
144   private final ConfigurationRepository configurationRepository;
145   private final SearchIndexRepository searchIndexRepository;
146   private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
147   private final UploadRepository uploadRepository;
148   private final PageRepository pageRepository;
149   private final JdbcClient jdbcClient;
150 
151   @Value("${spatial.dbs.connect:false}")
152   private boolean connectToSpatialDbs;
153 
154   @Value("#{'${tailormap-api.database.populate-testdata.categories}'.split(',')}")
155   private Set<String> categories;
156 
157   @Value("${spatial.dbs.localhost:true}")
158   private boolean connectToSpatialDbsAtLocalhost;
159 
160   @Value("${tailormap-api.database.populate-testdata.admin-hashed-password}")
161   private String adminHashedPassword;
162 
163   @Value("${tailormap-api.database.populate-testdata.management-hashed-password}")
164   private String managementHashedPassword;
165 
166   @Value("${tailormap-api.database.populate-testdata.exit:false}")
167   private boolean exit;
168 
169   @Value("${MAP5_URL:#{null}}")
170   private String map5url;
171 
172   private final String tiles3dProxyUsername = "b3p";
173 
174   // Password is for proxying a public service for testing purposes, not really secret
175   private final String tiles3dProxyPassword = "cOxPlJFWmtndFk0eVg5VdL";
176 
177   @Value("${tailormap-api.solr-batch-size:1000}")
178   private int solrBatchSize;
179 
180   @Value("${tailormap-api.solr-geometry-validation-rule:repairBuffer0}")
181   private String solrGeometryValidationRule;
182 
183   @Value("${tailormap-api.timeout}")
184   private int timeout;
185 
186   private static final String PROVINCIE_FEATURE_TYPE_NAME = "bestuurlijkegebieden:Provinciegebied";
187 
188   public PopulateTestData(
189       ApplicationContext appContext,
190       UserRepository userRepository,
191       GroupRepository groupRepository,
192       CatalogRepository catalogRepository,
193       GeoServiceRepository geoServiceRepository,
194       GeoServiceHelper geoServiceHelper,
195       SolrService solrService,
196       TaskManagerService taskManagerService,
197       FeatureSourceRepository featureSourceRepository,
198       ApplicationRepository applicationRepository,
199       ConfigurationRepository configurationRepository,
200       FeatureSourceFactoryHelper featureSourceFactoryHelper,
201       SearchIndexRepository searchIndexRepository,
202       UploadRepository uploadRepository,
203       PageRepository pageRepository,
204       JdbcClient jdbcClient) {
205     this.appContext = appContext;
206     this.userRepository = userRepository;
207     this.groupRepository = groupRepository;
208     this.catalogRepository = catalogRepository;
209     this.geoServiceRepository = geoServiceRepository;
210     this.geoServiceHelper = geoServiceHelper;
211     this.solrService = solrService;
212     this.taskManagerService = taskManagerService;
213     this.featureSourceRepository = featureSourceRepository;
214     this.applicationRepository = applicationRepository;
215     this.configurationRepository = configurationRepository;
216     this.featureSourceFactoryHelper = featureSourceFactoryHelper;
217     this.searchIndexRepository = searchIndexRepository;
218     this.uploadRepository = uploadRepository;
219     this.pageRepository = pageRepository;
220     this.jdbcClient = jdbcClient;
221   }
222 
223   @EventListener(ApplicationReadyEvent.class)
224   @Transactional
225   public void populate() throws Exception {
226     InternalAdminAuthentication.setInSecurityContext();
227     try {
228       // Used in conjunction with tailormap-api.database.clean=true so the database has been cleaned
229       // and the latest schema re-created
230       createTestUsersAndGroups();
231       createConfigurationTestData();
232       if (categories.contains("catalog")) {
233         createCatalogTestData();
234       }
235       if (categories.contains("apps")) {
236         createAppTestData();
237       }
238       if (categories.contains("search-index")) {
239         try {
240           createSolrIndex();
241         } catch (Exception e) {
242           logger.error("Exception creating Solr Index for testdata (continuing)", e);
243         }
244       }
245       if (categories.contains("search-index")) {
246         createSearchIndexTasks();
247       }
248       if (categories.contains("pages")) {
249         createPages();
250       }
251       logger.info("Test entities created");
252       if (categories.contains("uploads")) {
253         createUploads();
254         logger.info("Test uploads created");
255       }
256       if (categories.contains("drawing")) {
257         insertTestDrawing();
258         logger.info("Test drawing created");
259         try {
260           insertDrawingStyle();
261           logger.info("Test drawing style created");
262         } catch (Exception e) {
263           logger.error("Exception creating drawing style", e);
264         }
265       }
266     } finally {
267       InternalAdminAuthentication.clearSecurityContextAuthentication();
268     }
269     if (exit) {
270       // Exit after transaction is completed - for 'mvn verify' to populate testdata before
271       // integration tests
272       new Thread(() -> {
273             try {
274               logger.info("Exiting in 10 seconds");
275               Thread.sleep(10000);
276             } catch (InterruptedException ignored) {
277               // Ignore
278             }
279             SpringApplication.exit(appContext, () -> 0);
280             System.exit(0);
281           })
282           .start();
283     }
284   }
285 
286   public void createTestUsersAndGroups() throws NoSuchElementException {
287     Group groupFoo = new Group().setName("test-foo").setDescription("Used for integration tests.");
288     groupRepository.save(groupFoo);
289 
290     Group groupBar = new Group().setName("test-bar").setDescription("Used for integration tests.");
291     groupBar.addOrUpdateAdminProperty("group-property", true, true);
292     groupBar.addOrUpdateAdminProperty("group-private-property", 999.9, false);
293     groupRepository.save(groupBar);
294 
295     Group groupBaz = new Group().setName("test-baz").setDescription("Used for integration tests.");
296     groupRepository.save(groupBaz);
297 
298     // Normal user
299     User u = new User().setUsername("user").setPassword("{noop}user").setEmail("user@example.com");
300     u.getGroups().addAll(List.of(groupFoo, groupBar, groupBaz));
301     userRepository.save(u);
302 
303     // Foo only user
304     u = new User()
305         .setUsername("foo")
306         .setPassword("{noop}foo")
307         .setEmail("foo@example.com")
308         .setName("Foo only user");
309     u.getGroups().add(groupFoo);
310     userRepository.save(u);
311 
312     u = new User()
313         .setUsername("disabled")
314         .setPassword("{noop}disabled")
315         .setEmail("disabled@example.com")
316         .setEnabled(false);
317     userRepository.save(u);
318 
319     u = new User()
320         .setUsername("expired")
321         .setPassword("{noop}expired")
322         .setEmail("expired@example.com")
323         .setValidUntil(
324             OffsetDateTime.now(ZoneId.systemDefault()).minusDays(1).toZonedDateTime());
325     userRepository.save(u);
326 
327     // Superuser with all access
328     u = new User().setUsername("tm-admin").setPassword(adminHashedPassword);
329     u.addOrUpdateAdminProperty("some-property", "some-value", true);
330     u.addOrUpdateAdminProperty("admin-property", "private-value", false);
331     u.getGroups().add(groupRepository.findById(Group.ADMIN).orElseThrow());
332     u.getGroups().add(groupBar);
333     userRepository.save(u);
334 
335     u = new User().setUsername(Group.ACTUATOR).setPassword(managementHashedPassword);
336     u.getGroups().add(groupRepository.findById(Group.ACTUATOR).orElseThrow());
337     userRepository.save(u);
338   }
339 
340   private final List<AuthorizationRule> ruleAnonymousRead = List.of(new AuthorizationRule()
341       .groupName(Group.ANONYMOUS)
342       .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)));
343 
344   private final List<AuthorizationRule> ruleLoggedIn = List.of(new AuthorizationRule()
345       .groupName(Group.AUTHENTICATED)
346       .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)));
347 
348   @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
349   private void createCatalogTestData() throws Exception {
350     Catalog catalog = catalogRepository.findById(Catalog.MAIN).orElseThrow();
351     CatalogNode rootCatalogNode = catalog.getNodes().getFirst();
352     CatalogNode catalogNode = new CatalogNode().id("test").title("Test services");
353     rootCatalogNode.addChildrenItem(catalogNode.getId());
354     catalog.getNodes().add(catalogNode);
355 
356     String osmAttribution = "© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors";
357 
358     Bounds rdTileGridExtent =
359         new Bounds().minx(-285401.92).maxx(595401.92).miny(22598.08).maxy(903401.92);
360 
361     Upload legend = new Upload()
362         .setCategory(UploadCategory.LEGEND)
363         .setFilename("gemeentegebied-legend.png")
364         .setMimeType("image/png")
365         .setContent(new ClassPathResource("test/gemeentegebied-legend.png").getContentAsByteArray())
366         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
367     uploadRepository.save(legend);
368 
369     Upload pdokLogo = uploadRepository.save(new Upload()
370         .setCategory(UploadCategory.LAYER_ATTACHED_FILE)
371         .setFilename("pdok_logo.png")
372         .setMimeType("image/png")
373         .setDescription("PDOK logo for testing layer attached uploads")
374         .setContent(new ClassPathResource("test/pdok_logo.png").getContentAsByteArray())
375         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault())));
376 
377     Collection<GeoService> services = List.of(
378         new GeoService()
379             .setId("demo")
380             .setProtocol(WMS)
381             .setTitle("Demo")
382             .setPublished(true)
383             .setAuthorizationRules(ruleAnonymousRead)
384             .setUrl("https://demo.tailormap.com/geoserver/geodata/ows?SERVICE=WMS"),
385         new GeoService()
386             .setId("osm")
387             .setProtocol(XYZ)
388             .setTitle("OSM")
389             .setUrl("https://tile.openstreetmap.org/{z}/{x}/{y}.png")
390             .setAuthorizationRules(ruleAnonymousRead)
391             .setSettings(new GeoServiceSettings()
392                 .xyzCrs("EPSG:3857")
393                 .layerSettings(Map.of(
394                     "xyz",
395                     new GeoServiceLayerSettings()
396                         .attribution(osmAttribution)
397                         .maxZoom(19)))),
398         // Layer settings configured later, using the same settings for this one and proxied one
399         new GeoService()
400             .setId("snapshot-geoserver")
401             .setProtocol(WMS)
402             .setTitle("Test GeoServer")
403             .setUrl("https://snapshot.tailormap.nl/geoserver/wms")
404             .setAuthorizationRules(ruleAnonymousRead)
405             .setPublished(true),
406         new GeoService()
407             .setId("filtered-snapshot-geoserver")
408             .setProtocol(WMS)
409             .setTitle("Test GeoServer (with authorization rules)")
410             .setUrl("https://snapshot.tailormap.nl/geoserver/wms")
411             .setAuthorizationRules(List.of(
412                 new AuthorizationRule()
413                     .groupName("test-foo")
414                     .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)),
415                 new AuthorizationRule()
416                     .groupName("test-baz")
417                     .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW))))
418             .setSettings(new GeoServiceSettings()
419                 .layerSettings(Map.of(
420                     "BGT",
421                     new GeoServiceLayerSettings()
422                         .addAuthorizationRulesItem(new AuthorizationRule()
423                             .groupName("test-foo")
424                             .decisions(Map.of(
425                                 ACCESS_TYPE_VIEW, AuthorizationRuleDecision.DENY)))
426                         .addAuthorizationRulesItem(new AuthorizationRule()
427                             .groupName("test-baz")
428                             .decisions(Map.of(
429                                 ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW))))))
430             .setPublished(true),
431         new GeoService()
432             .setId("snapshot-geoserver-proxied")
433             .setProtocol(WMS)
434             .setTitle("Test GeoServer (proxied)")
435             .setUrl("https://snapshot.tailormap.nl/geoserver/wms")
436             .setAuthorizationRules(ruleAnonymousRead)
437             .setSettings(new GeoServiceSettings().useProxy(true)),
438         new GeoService()
439             .setId("openbasiskaart")
440             .setProtocol(WMTS)
441             .setTitle("Openbasiskaart")
442             .setUrl("https://www.openbasiskaart.nl/mapcache/wmts")
443             .setAuthorizationRules(ruleAnonymousRead)
444             .setSettings(new GeoServiceSettings()
445                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings().attribution(osmAttribution))
446                 .layerSettings(Map.of(
447                     "osm",
448                     new GeoServiceLayerSettings()
449                         .title("Openbasiskaart")
450                         .hiDpiDisabled(false)
451                         .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERSHOWNEXTZOOMLEVEL)
452                         .hiDpiSubstituteLayer("osm-hq")))),
453         new GeoService()
454             .setId("openbasiskaart-proxied")
455             .setProtocol(WMTS)
456             .setTitle("Openbasiskaart (proxied)")
457             .setUrl("https://www.openbasiskaart.nl/mapcache/wmts")
458             .setAuthorizationRules(ruleAnonymousRead)
459             // The service actually doesn't require authentication, but also doesn't mind it
460             // Just for testing
461             .setAuthentication(new ServiceAuthentication()
462                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
463                 .username("test")
464                 .password("test"))
465             .setSettings(new GeoServiceSettings()
466                 .useProxy(true)
467                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings().attribution(osmAttribution))
468                 .layerSettings(Map.of(
469                     "osm",
470                     new GeoServiceLayerSettings()
471                         .hiDpiDisabled(false)
472                         .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERSHOWNEXTZOOMLEVEL)
473                         .hiDpiSubstituteLayer("osm-hq")))),
474         new GeoService()
475             .setId("openbasiskaart-tms")
476             .setProtocol(XYZ)
477             .setTitle("Openbasiskaart (TMS)")
478             .setUrl("https://openbasiskaart.nl/mapcache/tms/1.0.0/osm@rd/{z}/{x}/{-y}.png")
479             .setAuthorizationRules(ruleAnonymousRead)
480             .setSettings(
481                 new GeoServiceSettings()
482                     .xyzCrs("EPSG:28992")
483                     .defaultLayerSettings(
484                         new GeoServiceDefaultLayerSettings().attribution(osmAttribution))
485                     .layerSettings(
486                         Map.of(
487                             "xyz",
488                             new GeoServiceLayerSettings()
489                                 .maxZoom(15)
490                                 .tileGridExtent(rdTileGridExtent)
491                                 .hiDpiDisabled(false)
492                                 .hiDpiMode(
493                                     TileLayerHiDpiMode
494                                         .SUBSTITUTELAYERTILEPIXELRATIOONLY)
495                                 .hiDpiSubstituteLayer(
496                                     "https://openbasiskaart.nl/mapcache/tms/1.0.0/osm-hq@rd-hq/{z}/{x}/{-y}.png")))),
497         new GeoService()
498             .setId("pdok-hwh-luchtfotorgb")
499             .setProtocol(WMTS)
500             .setTitle("PDOK HWH luchtfoto")
501             .setUrl("https://service.pdok.nl/hwh/luchtfotorgb/wmts/v1_0")
502             .setAuthorizationRules(ruleAnonymousRead)
503             .setPublished(true)
504             .setSettings(new GeoServiceSettings()
505                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
506                     .attribution("© [Beeldmateriaal.nl](https://beeldmateriaal.nl)")
507                     .hiDpiDisabled(false))
508                 .putLayerSettingsItem(
509                     "Actueel_orthoHR", new GeoServiceLayerSettings().title("Luchtfoto"))),
510         new GeoService()
511             .setId("b3p-mapproxy-luchtfoto")
512             .setProtocol(XYZ)
513             .setTitle("Luchtfoto (TMS)")
514             .setUrl("https://mapproxy.b3p.nl/tms/1.0.0/luchtfoto/EPSG28992/{z}/{x}/{-y}.jpeg")
515             .setAuthorizationRules(ruleAnonymousRead)
516             .setPublished(true)
517             .setSettings(new GeoServiceSettings()
518                 .xyzCrs("EPSG:28992")
519                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
520                     .attribution("© [Beeldmateriaal.nl](https://beeldmateriaal.nl)")
521                     .hiDpiDisabled(false))
522                 .layerSettings(Map.of(
523                     "xyz",
524                     new GeoServiceLayerSettings()
525                         .maxZoom(14)
526                         .tileGridExtent(rdTileGridExtent)
527                         .hiDpiMode(TileLayerHiDpiMode.SHOWNEXTZOOMLEVEL)))),
528         new GeoService()
529             .setId("at-basemap")
530             .setProtocol(WMTS)
531             .setTitle("basemap.at")
532             .setUrl("https://mapsneu.wien.gv.at/basemapneu/1.0.0/WMTSCapabilities.xml")
533             .setAuthorizationRules(ruleAnonymousRead)
534             .setPublished(true)
535             .setSettings(new GeoServiceSettings()
536                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
537                     .attribution("© [basemap.at](https://basemap.at)")
538                     .hiDpiDisabled(true))
539                 .layerSettings(Map.of(
540                     "geolandbasemap",
541                     new GeoServiceLayerSettings()
542                         .title("Basemap")
543                         .hiDpiDisabled(false)
544                         .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERTILEPIXELRATIOONLY)
545                         .hiDpiSubstituteLayer("bmaphidpi"),
546                     "bmaporthofoto30cm",
547                     new GeoServiceLayerSettings()
548                         .title("Orthophoto")
549                         .hiDpiDisabled(false)))),
550         new GeoService()
551             .setId("pdok-kadaster-bestuurlijkegebieden")
552             .setProtocol(WMS)
553             .setUrl("https://service.pdok.nl/kadaster/brk-bestuurlijke-gebieden/wms/v1_0?service=WMS")
554             .setAuthorizationRules(ruleAnonymousRead)
555             .setSettings(new GeoServiceSettings()
556                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
557                     .description("This layer shows an administrative boundary.\n\n[logo]("
558                         + UploadsService.UPLOAD_MARKDOWN_SCHEME + pdokLogo.getId() + ")"))
559                 // No attribution required: service is CC0
560                 .serverType(GeoServiceSettings.ServerTypeEnum.MAPSERVER)
561                 .useProxy(true)
562                 .putLayerSettingsItem(
563                     "Gemeentegebied",
564                     new GeoServiceLayerSettings()
565                         .legendImageId(legend.getId().toString())))
566             .setPublished(true)
567             .setTitle("PDOK Kadaster bestuurlijke gebieden"),
568         new GeoService()
569             .setId("bestuurlijkegebieden-proxied")
570             .setProtocol(WMS)
571             .setUrl("https://service.pdok.nl/kadaster/brk-bestuurlijke-gebieden/wms/v1_0?service=WMS")
572             .setAuthorizationRules(ruleAnonymousRead)
573             // The service actually doesn't require authentication, but also doesn't mind it
574             // Just for testing that proxied services with auth are not available in public
575             // apps (even when logged in), in any controllers (map, proxy, features)
576             .setAuthentication(new ServiceAuthentication()
577                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
578                 .username("test")
579                 .password("test"))
580             .setSettings(new GeoServiceSettings()
581                 // No attribution required: service is CC0
582                 .serverType(GeoServiceSettings.ServerTypeEnum.MAPSERVER)
583                 .useProxy(true))
584             .setPublished(true)
585             .setTitle("Bestuurlijke gebieden (proxied met auth)"),
586         new GeoService()
587             .setId("3dbag_utrecht")
588             .setProtocol(TILES3D)
589             .setUrl("https://3dtilesnederland.nl/tiles/1.0/implicit/nederland/344.json")
590             .setTitle("3D BAG Utrecht")
591             .setPublished(true)
592             .setAuthorizationRules(ruleAnonymousRead),
593         new GeoService()
594             .setId("ahn_terrain_model")
595             .setProtocol(QUANTIZEDMESH)
596             .setUrl(
597                 "https://api.pdok.nl/kadaster/3d-basisvoorziening/ogc/v1/collections/digitaalterreinmodel")
598             .setTitle("AHN Terrain Model")
599             .setPublished(true)
600             .setAuthorizationRules(ruleAnonymousRead),
601         new GeoService()
602             .setId("3d_basisvoorziening_gebouwen_proxy")
603             .setProtocol(TILES3D)
604             .setUrl(
605                 "https://api.pdok.nl/kadaster/3d-basisvoorziening/ogc/v1_0/collections/gebouwen/3dtiles")
606             .setTitle("3D Basisvoorziening Gebouwen Proxy")
607             .setPublished(true)
608             .setAuthorizationRules(ruleAnonymousRead)
609             .setSettings(new GeoServiceSettings().useProxy(true)),
610         new GeoService()
611             .setId("3d_utrecht_proxied_auth")
612             .setProtocol(TILES3D)
613             .setUrl("https://snapshot.tailormap.nl/tiles3d-proxy/3dtiles")
614             .setTitle("3D Utrecht Proxied with Authorization")
615             .setPublished(true)
616             .setAuthorizationRules(ruleAnonymousRead)
617             .setAuthentication(new ServiceAuthentication()
618                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
619                 .username(tiles3dProxyUsername)
620                 .password(tiles3dProxyPassword))
621             .setSettings(new GeoServiceSettings().useProxy(true))
622         // TODO MapServer WMS "https://wms.geonorge.no/skwms1/wms.adm_enheter_historisk"
623         );
624 
625     if (map5url != null) {
626       GeoServiceLayerSettings osmAttr = new GeoServiceLayerSettings().attribution(osmAttribution);
627       GeoServiceLayerSettings map5Attr = new GeoServiceLayerSettings()
628           .attribution("Kaarten: [Map5.nl](https://map5.nl), data: " + osmAttribution);
629       services = new ArrayList<>(services);
630       services.add(new GeoService()
631           .setId("map5")
632           .setProtocol(WMTS)
633           .setTitle("Map5")
634           .setUrl(map5url)
635           .setAuthorizationRules(ruleAnonymousRead)
636           .setSettings(new GeoServiceSettings()
637               .defaultLayerSettings(new GeoServiceDefaultLayerSettings().hiDpiDisabled(true))
638               .layerSettings(Map.of(
639                   "openlufo",
640                   new GeoServiceLayerSettings()
641                       .attribution("© [Beeldmateriaal.nl](https://beeldmateriaal.nl), "
642                           + osmAttribution),
643                   "luforoadslabels",
644                   osmAttr,
645                   "map5topo",
646                   new GeoServiceLayerSettings()
647                       .attribution(map5Attr.getAttribution())
648                       .hiDpiDisabled(false)
649                       .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERSHOWNEXTZOOMLEVEL)
650                       .hiDpiSubstituteLayer("map5topo_hq"),
651                   "map5topo_gray",
652                   map5Attr,
653                   "map5topo_simple",
654                   map5Attr,
655                   "map5topo_simple_gray",
656                   map5Attr,
657                   "opensimpletopo",
658                   osmAttr,
659                   "opensimpletopo_gray",
660                   osmAttr,
661                   "opentopo",
662                   osmAttr,
663                   "opentopo_gray",
664                   osmAttr))));
665     }
666 
667     for (GeoService geoService : services) {
668       try {
669         geoServiceHelper.loadServiceCapabilities(geoService);
670       } catch (Exception e) {
671         logger.error(
672             "Error loading capabilities for {} service URL {}: {}, {}",
673             geoService.getProtocol().getValue(),
674             geoService.getUrl(),
675             e.getClass(),
676             e.getMessage());
677       }
678 
679       geoServiceRepository.save(geoService);
680       catalogNode.addItemsItem(new TailormapObjectRef()
681           .kind(TailormapObjectRef.KindEnum.GEO_SERVICE)
682           .id(geoService.getId()));
683     }
684 
685     List<TMFeatureSource> testWFS = List.of(
686         //            "demo",
687         new TMFeatureSource()
688             .setProtocol(TMFeatureSource.Protocol.WFS)
689             .setUrl("https://demo.tailormap.com/geoserver/geodata/wfs")
690             .setTitle("WFS for Demo")
691             .setLinkedService(geoServiceRepository.findById("demo").orElseThrow()),
692         //            "snapshot-geoserver",
693         new TMFeatureSource()
694             .setProtocol(TMFeatureSource.Protocol.WFS)
695             .setUrl("https://snapshot.tailormap.nl/geoserver/wfs?")
696             .setTitle("WFS for Test GeoServer")
697             .setLinkedService(geoServiceRepository
698                 .findById("snapshot-geoserver")
699                 .orElseThrow()));
700     featureSourceRepository.saveAll(testWFS);
701 
702     CatalogNode wfsFeatureSourceCatalogNode =
703         new CatalogNode().id("wfs_feature_sources").title("WFS feature sources");
704     rootCatalogNode.addChildrenItem(wfsFeatureSourceCatalogNode.getId());
705     catalog.getNodes().add(wfsFeatureSourceCatalogNode);
706 
707     for (TMFeatureSource featureSource : testWFS) {
708       new WFSFeatureSourceHelper()
709           .loadCapabilities(
710               featureSource,
711               timeout,
712               (CapabilitiesLoadingProgressReporting) event -> {
713                 logger.warn("WFS loading: {}", event);
714               },
715               new CapabilitiesLoadingEvent()
716                   .id(featureSource.getId().toString())
717                   .title(featureSource.getTitle())
718                   .message("Loading capabilities for " + featureSource.getTitle()
719                       + " feature source"));
720       wfsFeatureSourceCatalogNode.addItemsItem(new TailormapObjectRef()
721           .kind(TailormapObjectRef.KindEnum.FEATURE_SOURCE)
722           .id(featureSource.getId().toString()));
723     }
724 
725     String geodataPassword = "980f1c8A-25933b2";
726 
727     Map<String, TMFeatureSource> featureSources = Map.of(
728         "postgis",
729         new TMFeatureSource()
730             .setProtocol(TMFeatureSource.Protocol.JDBC)
731             .setTitle("PostGIS")
732             .setJdbcConnection(new JDBCConnectionProperties()
733                 .dbtype(JDBCConnectionProperties.DbtypeEnum.POSTGIS)
734                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "postgis")
735                 .port(connectToSpatialDbsAtLocalhost ? 54322 : 5432)
736                 .database("geodata")
737                 .schema("public")
738                 .additionalProperties(Map.of("connectionOptions", "?ApplicationName=tailormap-api")))
739             .setAuthentication(new ServiceAuthentication()
740                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
741                 .username("geodata")
742                 .password(geodataPassword)),
743         "postgis_osm",
744         new TMFeatureSource()
745             .setProtocol(TMFeatureSource.Protocol.JDBC)
746             .setTitle("PostGIS OSM")
747             .setJdbcConnection(new JDBCConnectionProperties()
748                 .dbtype(JDBCConnectionProperties.DbtypeEnum.POSTGIS)
749                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "postgis")
750                 .port(connectToSpatialDbsAtLocalhost ? 54322 : 5432)
751                 .database("geodata")
752                 .schema("osm")
753                 .additionalProperties(Map.of("connectionOptions", "?ApplicationName=tailormap-api")))
754             .setAuthentication(new ServiceAuthentication()
755                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
756                 .username("geodata")
757                 .password(geodataPassword)),
758         "oracle",
759         new TMFeatureSource()
760             .setProtocol(TMFeatureSource.Protocol.JDBC)
761             .setTitle("Oracle")
762             .setJdbcConnection(new JDBCConnectionProperties()
763                 .dbtype(JDBCConnectionProperties.DbtypeEnum.ORACLE)
764                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "oracle")
765                 .database("/FREEPDB1")
766                 .schema("GEODATA")
767                 .additionalProperties(Map.of("connectionOptions", "?oracle.jdbc.J2EE13Compliant=true")))
768             .setAuthentication(new ServiceAuthentication()
769                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
770                 .username("geodata")
771                 .password(geodataPassword)),
772         "sqlserver",
773         new TMFeatureSource()
774             .setProtocol(TMFeatureSource.Protocol.JDBC)
775             .setTitle("MS SQL Server")
776             .setJdbcConnection(new JDBCConnectionProperties()
777                 .dbtype(JDBCConnectionProperties.DbtypeEnum.SQLSERVER)
778                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "sqlserver")
779                 .database("geodata")
780                 .schema("dbo")
781                 .additionalProperties(Map.of("connectionOptions", ";encrypt=false")))
782             .setAuthentication(new ServiceAuthentication()
783                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
784                 .username("geodata")
785                 .password(geodataPassword)),
786         "pdok-kadaster-bestuurlijkegebieden",
787         new TMFeatureSource()
788             .setProtocol(TMFeatureSource.Protocol.WFS)
789             .setUrl(
790                 "https://service.pdok.nl/kadaster/brk-bestuurlijke-gebieden/wfs/v1_0?service=WFS&VERSION=2.0.0")
791             .setTitle("Bestuurlijke gebieden")
792             .setNotes(
793                 "Overzicht van de bestuurlijke indeling van Nederland in gemeenten en provincies alsmede de rijksgrens. Gegevens zijn afgeleid uit de Basisregistratie Kadaster (BRK)."));
794     featureSourceRepository.saveAll(featureSources.values());
795 
796     new WFSFeatureSourceHelper()
797         .loadCapabilities(
798             featureSources.get("pdok-kadaster-bestuurlijkegebieden"),
799             timeout,
800             (CapabilitiesLoadingProgressReporting) event -> {
801               logger.warn("WFS loading: {}", event);
802             },
803             new CapabilitiesLoadingEvent()
804                 .id("pdok-kadaster-bestuurlijkegebieden")
805                 .title(featureSources
806                     .get("pdok-kadaster-bestuurlijkegebieden")
807                     .getTitle())
808                 .message("Loading capabilities for pdok-kadaster-bestuurlijkegebieden feature source"));
809     geoServiceRepository.findById("pdok-kadaster-bestuurlijkegebieden").ifPresent(geoService -> {
810       geoService
811           .getSettings()
812           .getLayerSettings()
813           .put(
814               "Provinciegebied",
815               new GeoServiceLayerSettings()
816                   .description("The administrative boundary of Dutch Provinces, connected to a WFS.")
817                   .featureType(new FeatureTypeRef()
818                       .featureSourceId(featureSources
819                           .get("pdok-kadaster-bestuurlijkegebieden")
820                           .getId())
821                       .featureTypeName(PROVINCIE_FEATURE_TYPE_NAME))
822                   .title("Provinciegebied (WFS)"));
823       geoServiceRepository.save(geoService);
824     });
825 
826     geoServiceRepository.findById("bestuurlijkegebieden-proxied").ifPresent(geoService -> {
827       geoService
828           .getSettings()
829           .getLayerSettings()
830           .put(
831               "Provinciegebied",
832               new GeoServiceLayerSettings()
833                   .featureType(new FeatureTypeRef()
834                       .featureSourceId(featureSources
835                           .get("pdok-kadaster-bestuurlijkegebieden")
836                           .getId())
837                       .featureTypeName(PROVINCIE_FEATURE_TYPE_NAME))
838                   .title("Provinciegebied (WFS, proxied met auth)"));
839       geoServiceRepository.save(geoService);
840     });
841 
842     CatalogNode featureSourceCatalogNode =
843         new CatalogNode().id("feature_sources").title("Test feature sources");
844     rootCatalogNode.addChildrenItem(featureSourceCatalogNode.getId());
845     catalog.getNodes().add(featureSourceCatalogNode);
846 
847     CatalogNode geoServiceAdminControllerNode =
848         new CatalogNode().id("GeoServiceAdminController").title("GeoServiceAdminController test node");
849     rootCatalogNode.addChildrenItem(geoServiceAdminControllerNode.getId());
850     catalog.getNodes().add(geoServiceAdminControllerNode);
851     CatalogNode featureSourceAdminControllerNode =
852         new CatalogNode().id("FeatureSourceAdminController").title("FeatureSourceAdminController test node");
853     rootCatalogNode.addChildrenItem(featureSourceAdminControllerNode.getId());
854     catalog.getNodes().add(featureSourceAdminControllerNode);
855 
856     for (TMFeatureSource featureSource : featureSources.values()) {
857       featureSourceCatalogNode.addItemsItem(new TailormapObjectRef()
858           .kind(TailormapObjectRef.KindEnum.FEATURE_SOURCE)
859           .id(featureSource.getId().toString()));
860     }
861     catalogRepository.save(catalog);
862 
863     if (connectToSpatialDbs) {
864       featureSources.values().forEach(fs -> {
865         try {
866           if (fs.getProtocol() == TMFeatureSource.Protocol.JDBC) {
867             new JDBCFeatureSourceHelper()
868                 .loadCapabilities(
869                     fs,
870                     timeout,
871                     (CapabilitiesLoadingProgressReporting) event -> {
872                       logger.debug("JDBC loading: {}", event);
873                     },
874                     new CapabilitiesLoadingEvent()
875                         .id(fs.getId().toString())
876                         .title(fs.getTitle())
877                         .message("Loading JDBC capabilities for " + fs.getTitle()
878                             + " feature source"));
879           } else if (fs.getProtocol() == TMFeatureSource.Protocol.WFS) {
880             new WFSFeatureSourceHelper()
881                 .loadCapabilities(
882                     fs,
883                     timeout,
884                     (CapabilitiesLoadingProgressReporting) event -> {
885                       logger.warn("WFS loading: {}", event);
886                     },
887                     new CapabilitiesLoadingEvent()
888                         .id(fs.getId().toString())
889                         .title(fs.getTitle())
890                         .message("Loading WFS capabilities for " + fs.getTitle()
891                             + " feature source"));
892           }
893         } catch (Exception e) {
894           logger.error("Error loading capabilities for feature source {}", fs.getTitle(), e);
895         }
896       });
897 
898       services.stream()
899           // Set layer settings for both the proxied and non-proxied one, but don't overwrite the
900           // authorization rules for the "filtered-snapshot-geoserver" service
901           .filter(s -> s.getId().startsWith("snapshot-geoserver"))
902           .forEach(s -> s.getSettings()
903               .layerSettings(Map.of(
904                   "postgis:begroeidterreindeel",
905                   new GeoServiceLayerSettings()
906                       .description("""
907 This layer shows data from https://www.postgis.net/
908 
909 https://postgis.net/brand.svg""")
910                       .featureType(new FeatureTypeRef()
911                           .featureSourceId(featureSources
912                               .get("postgis")
913                               .getId())
914                           .featureTypeName("begroeidterreindeel")),
915                   "postgis:bak",
916                   new GeoServiceLayerSettings()
917                       .featureType(new FeatureTypeRef()
918                           .featureSourceId(featureSources
919                               .get("postgis")
920                               .getId())
921                           .featureTypeName("bak")),
922                   "postgis:kadastraal_perceel",
923                   new GeoServiceLayerSettings()
924                       .description("cadastral parcel label points")
925                       .featureType(new FeatureTypeRef()
926                           .featureSourceId(featureSources
927                               .get("postgis")
928                               .getId())
929                           .featureTypeName("kadastraal_perceel")),
930                   "sqlserver:wegdeel",
931                   new GeoServiceLayerSettings()
932                       .attribution(
933                           "CC BY 4.0 [BGT/Kadaster](https://www.nationaalgeoregister.nl/geonetwork/srv/api/records/2cb4769c-b56e-48fa-8685-c48f61b9a319)")
934                       .description("""
935 This layer shows data from [MS SQL Server](https://learn.microsoft.com/en-us/sql/relational-databases/spatial/spatial-data-sql-server).
936 
937 https://social.technet.microsoft.com/wiki/cfs-filesystemfile.ashx/__key/communityserver-components-imagefileviewer/communityserver-wikis-components-files-00-00-00-00-05/1884.SQL_5F00_h_5F00_rgb.png_2D00_550x0.png""")
938                       .featureType(new FeatureTypeRef()
939                           .featureSourceId(featureSources
940                               .get("sqlserver")
941                               .getId())
942                           .featureTypeName("wegdeel")),
943                   "oracle:WATERDEEL",
944                   new GeoServiceLayerSettings()
945                       .description("This layer shows data from Oracle Spatial.")
946                       .featureType(new FeatureTypeRef()
947                           .featureSourceId(featureSources
948                               .get("oracle")
949                               .getId())
950                           .featureTypeName("WATERDEEL")),
951                   "postgis:osm_polygon",
952                   new GeoServiceLayerSettings()
953                       .description("This layer shows OSM data from postgis.")
954                       .featureType(new FeatureTypeRef()
955                           .featureSourceId(featureSources
956                               .get("postgis_osm")
957                               .getId())
958                           .featureTypeName("osm_polygon")))));
959     }
960 
961     featureSources.get("pdok-kadaster-bestuurlijkegebieden").getFeatureTypes().stream()
962         .filter(ft -> ft.getName().equals(PROVINCIE_FEATURE_TYPE_NAME))
963         .findFirst()
964         .ifPresent(ft -> {
965           ft.getSettings().addHideAttributesItem("identificatie");
966           ft.getSettings().addHideAttributesItem("ligtInLandCode");
967           ft.getSettings().addHideAttributesItem("fuuid");
968           ft.getSettings().putAttributeSettingsItem("naam", new AttributeSettings().title("Naam"));
969           ft.getSettings()
970               .setTemplate(new FeatureTypeTemplate()
971                   .templateLanguage("simple")
972                   .markupLanguage("markdown")
973                   .template("""
974 ### Provincie
975 Deze provincie heet **{{naam}}** en ligt in _{{ligtInLandNaam}}_.
976 
977 | Attribuut | Waarde             |
978 | --------- | ------------------ |
979 | `code`    | {{code}}           |
980 | `naam`    | {{naam}}           |
981 | `ligt in` | {{ligtInLandNaam}} |"""));
982         });
983 
984     featureSources.get("postgis").getFeatureTypes().stream()
985         .filter(ft -> ft.getName().equals("begroeidterreindeel"))
986         .findFirst()
987         .ifPresent(ft -> {
988           ft.getSettings().addHideAttributesItem("terminationdate");
989           ft.getSettings().addHideAttributesItem("geom_kruinlijn");
990           ft.getSettings().putAttributeSettingsItem("gmlid", new AttributeSettings().title("GML ID"));
991           ft.getSettings()
992               .putAttributeSettingsItem("identificatie", new AttributeSettings().title("Identificatie"));
993           ft.getSettings()
994               .putAttributeSettingsItem(
995                   "tijdstipregistratie", new AttributeSettings().title("Registratie"));
996           ft.getSettings()
997               .putAttributeSettingsItem(
998                   "eindregistratie", new AttributeSettings().title("Eind registratie"));
999           ft.getSettings().putAttributeSettingsItem("class", new AttributeSettings().title("Klasse"));
1000           ft.getSettings()
1001               .putAttributeSettingsItem("bronhouder", new AttributeSettings().title("Bronhouder"));
1002           ft.getSettings()
1003               .putAttributeSettingsItem("inonderzoek", new AttributeSettings().title("In onderzoek"));
1004           ft.getSettings()
1005               .putAttributeSettingsItem(
1006                   "relatievehoogteligging", new AttributeSettings().title("Relatieve hoogteligging"));
1007           ft.getSettings()
1008               .putAttributeSettingsItem("bgt_status", new AttributeSettings().title("BGT status"));
1009           ft.getSettings()
1010               .putAttributeSettingsItem("plus_status", new AttributeSettings().title("Plus-status"));
1011           ft.getSettings()
1012               .putAttributeSettingsItem(
1013                   "plus_fysiekvoorkomen", new AttributeSettings().title("Plus-fysiek voorkomen"));
1014           ft.getSettings()
1015               .putAttributeSettingsItem(
1016                   "begroeidterreindeeloptalud", new AttributeSettings().title("Op talud"));
1017           ft.getSettings().addAttributeOrderItem("identificatie");
1018           ft.getSettings().addAttributeOrderItem("bronhouder");
1019           ft.getSettings().addAttributeOrderItem("class");
1020           ft.getSettings()
1021               .addAttachmentAttributesItem(new AttachmentAttributeType()
1022                   .attributeName("bijlage")
1023                   .maxAttachmentSize(4_000_000L)
1024                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
1025           try {
1026             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
1027           } catch (IOException | SQLException e) {
1028             throw new RuntimeException("Failed to create attachments table", e);
1029           }
1030           ft.getSettings().addEditableAttributesItem("identificatie");
1031           ft.getSettings().addEditableAttributesItem("bronhouder");
1032           ft.getSettings().addEditableAttributesItem("class");
1033           ft.getSettings().addEditableAttributesItem("gmlid");
1034           ft.getSettings().addEditableAttributesItem("lv_publicatiedatum");
1035           ft.getSettings().addEditableAttributesItem("creationdate");
1036           ft.getSettings().addEditableAttributesItem("tijdstipregistratie");
1037           ft.getSettings().addEditableAttributesItem("eindregistratie");
1038           ft.getSettings().addEditableAttributesItem("terminationdate");
1039           ft.getSettings().addEditableAttributesItem("inonderzoek");
1040           ft.getSettings().addEditableAttributesItem("relatievehoogteligging");
1041           ft.getSettings().addEditableAttributesItem("bgt_status");
1042           ft.getSettings().addEditableAttributesItem("plus_status");
1043           ft.getSettings().addEditableAttributesItem("plus_fysiekvoorkomen");
1044           ft.getSettings().addEditableAttributesItem("begroeidterreindeeloptalud");
1045         });
1046 
1047     featureSources.get("postgis").getFeatureTypes().stream()
1048         .filter(ft -> ft.getName().equals("bak"))
1049         .findFirst()
1050         .ifPresent(ft -> {
1051           ft.getSettings().addHideAttributesItem("gmlid");
1052           ft.getSettings().addHideAttributesItem("lv_publicatiedatum");
1053           ft.getSettings().addHideAttributesItem("creationdate");
1054           ft.getSettings().addHideAttributesItem("tijdstipregistratie");
1055           ft.getSettings().addHideAttributesItem("eindregistratie");
1056           ft.getSettings().addHideAttributesItem("terminationdate");
1057           ft.getSettings().addHideAttributesItem("inonderzoek");
1058           ft.getSettings().addHideAttributesItem("relatievehoogteligging");
1059           ft.getSettings().addHideAttributesItem("bgt_status");
1060           ft.getSettings().addHideAttributesItem("plus_status");
1061           ft.getSettings().addHideAttributesItem("function_");
1062           ft.getSettings().addHideAttributesItem("plus_type");
1063         });
1064 
1065     featureSources.get("oracle").getFeatureTypes().stream()
1066         .filter(ft -> ft.getName().equals("WATERDEEL"))
1067         .findFirst()
1068         .ifPresent(ft -> {
1069           ft.getSettings()
1070               .addAttachmentAttributesItem(new AttachmentAttributeType()
1071                   .attributeName("bijlage")
1072                   .maxAttachmentSize(4_000_000L)
1073                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
1074           try {
1075             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
1076           } catch (IOException | SQLException e) {
1077             throw new RuntimeException("Failed to create attachments table", e);
1078           }
1079           ft.getSettings().addEditableAttributesItem("GMLID");
1080           ft.getSettings().addEditableAttributesItem("IDENTIFICATIE");
1081           ft.getSettings().addEditableAttributesItem("LV_PUBLICATIEDATUM");
1082           ft.getSettings().addEditableAttributesItem("CREATIONDATE");
1083           ft.getSettings().addEditableAttributesItem("TIJDSTIPREGISTRATIE");
1084           ft.getSettings().addEditableAttributesItem("EINDREGISTRATIE");
1085           ft.getSettings().addEditableAttributesItem("TERMINATIONDATE");
1086           ft.getSettings().addEditableAttributesItem("BRONHOUDER");
1087           ft.getSettings().addEditableAttributesItem("INONDERZOEK");
1088           ft.getSettings().addEditableAttributesItem("RELATIEVEHOOGTELIGGING");
1089           ft.getSettings().addEditableAttributesItem("BGT_STATUS");
1090           ft.getSettings().addEditableAttributesItem("PLUS_STATUS");
1091           ft.getSettings().addEditableAttributesItem("CLASS");
1092           ft.getSettings().addEditableAttributesItem("PLUS_TYPE");
1093         });
1094     featureSources.get("sqlserver").getFeatureTypes().stream()
1095         .filter(ft -> ft.getName().equals("wegdeel"))
1096         .findFirst()
1097         .ifPresent(ft -> {
1098           ft.getSettings()
1099               .addAttachmentAttributesItem(new AttachmentAttributeType()
1100                   .attributeName("bijlage")
1101                   .maxAttachmentSize(4_000_000L)
1102                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
1103           try {
1104             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
1105           } catch (IOException | SQLException e) {
1106             throw new RuntimeException("Failed to create attachments table", e);
1107           }
1108           ft.getSettings().addEditableAttributesItem("gmlid");
1109           ft.getSettings().addEditableAttributesItem("identificatie");
1110           ft.getSettings().addEditableAttributesItem("lv_publicatiedatum");
1111           ft.getSettings().addEditableAttributesItem("creationdate");
1112           ft.getSettings().addEditableAttributesItem("tijdstipregistratie");
1113           ft.getSettings().addEditableAttributesItem("eindregistratie");
1114           ft.getSettings().addEditableAttributesItem("terminationdate");
1115           ft.getSettings().addEditableAttributesItem("bronhouder");
1116           ft.getSettings().addEditableAttributesItem("inonderzoek");
1117           ft.getSettings().addEditableAttributesItem("relatievehoogteligging");
1118           ft.getSettings().addEditableAttributesItem("bgt_status");
1119           ft.getSettings().addEditableAttributesItem("plus_status");
1120           ft.getSettings().addEditableAttributesItem("function_");
1121           ft.getSettings().addEditableAttributesItem("plus_functiewegdeel");
1122           ft.getSettings().addEditableAttributesItem("plus_fysiekvoorkomenwegdeel");
1123           ft.getSettings().addEditableAttributesItem("surfacematerial");
1124           ft.getSettings().addEditableAttributesItem("wegdeeloptalud");
1125         });
1126 
1127     featureSources.get("postgis").getFeatureTypes().stream()
1128         .filter(ft -> ft.getName().equals("kadastraal_perceel"))
1129         .findFirst()
1130         .ifPresent(ft -> {
1131           ft.getSettings()
1132               .addAttachmentAttributesItem(new AttachmentAttributeType()
1133                   .attributeName("bijlage")
1134                   .maxAttachmentSize(4_000_000L)
1135                   .mimeType("image/jpeg, image/svg+xml, .png, image/*"));
1136           try {
1137             AttachmentsHelper.createAttachmentTableForFeatureType(ft);
1138           } catch (IOException | SQLException e) {
1139             throw new RuntimeException("Failed to create attachments table for kadastraal_perceel", e);
1140           }
1141           // hide primary key
1142           ft.getSettings().addHideAttributesItem("gml_id");
1143           // make some attributes editable
1144           ft.getSettings().addEditableAttributesItem("begin_geldigheid");
1145           ft.getSettings().addEditableAttributesItem("tijdstip_registratie");
1146         });
1147 
1148     featureSources.get("postgis_osm").getFeatureTypes().stream()
1149         .filter(ft -> ft.getName().equals("osm_polygon"))
1150         .findFirst()
1151         .ifPresent(ft -> {
1152           ft.getSettings().addEditableAttributesItem("osm_id");
1153           ft.getSettings().addEditableAttributesItem("building");
1154           ft.getSettings().addEditableAttributesItem("z_order");
1155         });
1156   }
1157 
1158   public void createAppTestData() throws Exception {
1159     Upload logo = new Upload()
1160         .setCategory(UploadCategory.APP_LOGO)
1161         .setFilename("gradient.svg")
1162         .setMimeType("image/svg+xml")
1163         .setContent(new ClassPathResource("test/gradient-logo.svg").getContentAsByteArray())
1164         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
1165     uploadRepository.save(logo);
1166 
1167     List<AppTreeNode> baseNodes = List.of(
1168         new AppTreeLayerNode()
1169             .objectType("AppTreeLayerNode")
1170             .id("lyr:openbasiskaart:osm")
1171             .serviceId("openbasiskaart")
1172             .layerName("osm")
1173             .visible(true),
1174         new AppTreeLayerNode()
1175             .objectType("AppTreeLayerNode")
1176             .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR")
1177             .serviceId("pdok-hwh-luchtfotorgb")
1178             .layerName("Actueel_orthoHR")
1179             .visible(false));
1180 
1181     Application app = new Application()
1182         .setName("default")
1183         .setTitle("Tailormap demo")
1184         .setCrs("EPSG:28992")
1185         .setAuthorizationRules(ruleAnonymousRead)
1186         .setComponents(List.of(
1187             new Component()
1188                 .type("SIMPLE_SEARCH")
1189                 .config(new ComponentConfig()
1190                     .enabled(true)
1191                     .putAdditionalProperty("municipalities", List.of("0344"))),
1192             new Component().type("EDIT").config(new ComponentConfig().enabled(true)),
1193             new Component()
1194                 .type("TOC")
1195                 .config(new ComponentConfig()
1196                     .enabled(true)
1197                     .putAdditionalProperty("showEditLayerIcon", true)),
1198             new Component()
1199                 .type("COORDINATE_LINK_WINDOW")
1200                 .config(new ComponentConfig()
1201                     .enabled(true)
1202                     .putAdditionalProperty(
1203                         "urls",
1204                         List.of(
1205                             Map.of(
1206                                 "id",
1207                                 "google-maps",
1208                                 "url",
1209                                 "https://www.google.com/maps/@[lat],[lon],18z",
1210                                 "alias",
1211                                 "Google Maps",
1212                                 "projection",
1213                                 "EPSG:4326"),
1214                             Map.of(
1215                                 "id",
1216                                 "tm-demo",
1217                                 "url",
1218                                 "https://demo.tailormap.com/#@[X],[Y],18",
1219                                 "alias",
1220                                 "Tailormap demo",
1221                                 "projection",
1222                                 "EPSG:28992"))))))
1223         .setContentRoot(new AppContent()
1224             .addBaseLayerNodesItem(new AppTreeLevelNode()
1225                 .objectType("AppTreeLevelNode")
1226                 .id("root-base-layers")
1227                 .root(true)
1228                 .title("Base layers")
1229                 .childrenIds(List.of(
1230                     "lyr:openbasiskaart:osm",
1231                     "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR",
1232                     "lyr:openbasiskaart-proxied:osm",
1233                     "lyr:openbasiskaart-tms:xyz",
1234                     "lyr:b3p-mapproxy-luchtfoto:xyz")))
1235             .addBaseLayerNodesItem(
1236                 // This layer from a secured proxied service should not be proxyable in a
1237                 // public app, see test_wms_secured_proxy_not_in_public_app() testcase
1238                 new AppTreeLayerNode()
1239                     .objectType("AppTreeLayerNode")
1240                     .id("lyr:openbasiskaart-proxied:osm")
1241                     .serviceId("openbasiskaart-proxied")
1242                     .layerName("osm")
1243                     .visible(false))
1244             .addBaseLayerNodesItem(new AppTreeLayerNode()
1245                 .objectType("AppTreeLayerNode")
1246                 .id("lyr:openbasiskaart-tms:xyz")
1247                 .serviceId("openbasiskaart-tms")
1248                 .layerName("xyz")
1249                 .visible(false))
1250             .addBaseLayerNodesItem(new AppTreeLayerNode()
1251                 .objectType("AppTreeLayerNode")
1252                 .id("lyr:b3p-mapproxy-luchtfoto:xyz")
1253                 .serviceId("b3p-mapproxy-luchtfoto")
1254                 .layerName("xyz")
1255                 .visible(false))
1256             .addLayerNodesItem(new AppTreeLevelNode()
1257                 .objectType("AppTreeLevelNode")
1258                 .id("root")
1259                 .root(true)
1260                 .title("Layers")
1261                 .childrenIds(List.of(
1262                     "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1263                     "lyr:bestuurlijkegebieden-proxied:Provinciegebied",
1264                     "lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied",
1265                     "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1266                     "lyr:snapshot-geoserver:postgis:bak",
1267                     "lyr:snapshot-geoserver:postgis:kadastraal_perceel",
1268                     "lyr:snapshot-geoserver:sqlserver:wegdeel",
1269                     "lyr:snapshot-geoserver:oracle:WATERDEEL",
1270                     "lyr:snapshot-geoserver:BGT",
1271                     "lvl:proxied",
1272                     "lvl:osm",
1273                     "lvl:archeo")))
1274             .addLayerNodesItem(new AppTreeLayerNode()
1275                 .objectType("AppTreeLayerNode")
1276                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied")
1277                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1278                 .layerName("Provinciegebied")
1279                 .visible(true))
1280             // This is a layer from proxied service with auth that should also not be
1281             // visible, but it has a feature source attached, should also be denied for
1282             // features access and not be included in TOC
1283             .addLayerNodesItem(new AppTreeLayerNode()
1284                 .objectType("AppTreeLayerNode")
1285                 .id("lyr:bestuurlijkegebieden-proxied:Provinciegebied")
1286                 .serviceId("bestuurlijkegebieden-proxied")
1287                 .layerName("Provinciegebied")
1288                 .visible(false))
1289             .addLayerNodesItem(new AppTreeLayerNode()
1290                 .objectType("AppTreeLayerNode")
1291                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied")
1292                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1293                 .layerName("Gemeentegebied")
1294                 .visible(true))
1295             .addLayerNodesItem(new AppTreeLayerNode()
1296                 .objectType("AppTreeLayerNode")
1297                 .id("lyr:snapshot-geoserver:postgis:begroeidterreindeel")
1298                 .serviceId("snapshot-geoserver")
1299                 .layerName("postgis:begroeidterreindeel")
1300                 .visible(true))
1301             .addLayerNodesItem(new AppTreeLayerNode()
1302                 .objectType("AppTreeLayerNode")
1303                 .id("lyr:snapshot-geoserver:postgis:bak")
1304                 .serviceId("snapshot-geoserver")
1305                 .layerName("postgis:bak")
1306                 .visible(false))
1307             .addLayerNodesItem(new AppTreeLayerNode()
1308                 .objectType("AppTreeLayerNode")
1309                 .id("lyr:snapshot-geoserver:postgis:kadastraal_perceel")
1310                 .serviceId("snapshot-geoserver")
1311                 .layerName("postgis:kadastraal_perceel")
1312                 .visible(false))
1313             .addLayerNodesItem(new AppTreeLayerNode()
1314                 .objectType("AppTreeLayerNode")
1315                 .id("lyr:snapshot-geoserver:sqlserver:wegdeel")
1316                 .serviceId("snapshot-geoserver")
1317                 .layerName("sqlserver:wegdeel")
1318                 .visible(true))
1319             .addLayerNodesItem(new AppTreeLayerNode()
1320                 .objectType("AppTreeLayerNode")
1321                 .id("lyr:snapshot-geoserver:oracle:WATERDEEL")
1322                 .serviceId("snapshot-geoserver")
1323                 .layerName("oracle:WATERDEEL")
1324                 .visible(true))
1325             .addLayerNodesItem(new AppTreeLayerNode()
1326                 .objectType("AppTreeLayerNode")
1327                 .id("lyr:snapshot-geoserver:BGT")
1328                 .serviceId("snapshot-geoserver")
1329                 .layerName("BGT")
1330                 .visible(false))
1331             .addLayerNodesItem(new AppTreeLevelNode()
1332                 .objectType("AppTreeLevelNode")
1333                 .id("lvl:proxied")
1334                 .title("Proxied")
1335                 .childrenIds(List.of("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")))
1336             .addLayerNodesItem(new AppTreeLayerNode()
1337                 .objectType("AppTreeLayerNode")
1338                 .id("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")
1339                 .serviceId("snapshot-geoserver-proxied")
1340                 .layerName("postgis:begroeidterreindeel")
1341                 .visible(false))
1342             .addLayerNodesItem(new AppTreeLevelNode()
1343                 .objectType("AppTreeLevelNode")
1344                 .id("lvl:osm")
1345                 .title("OSM")
1346                 .childrenIds(List.of("lyr:snapshot-geoserver:postgis:osm_polygon")))
1347             .addLayerNodesItem(new AppTreeLayerNode()
1348                 .objectType("AppTreeLayerNode")
1349                 .id("lyr:snapshot-geoserver:postgis:osm_polygon")
1350                 .serviceId("snapshot-geoserver")
1351                 .layerName("postgis:osm_polygon")
1352                 .visible(false))
1353             .addLayerNodesItem(new AppTreeLevelNode()
1354                 .objectType("AppTreeLevelNode")
1355                 .id("lvl:archeo")
1356                 .title("Archeology")
1357                 .childrenIds(List.of("lyr:demo:geomorfologie")))
1358             .addLayerNodesItem(new AppTreeLayerNode()
1359                 .objectType("AppTreeLayerNode")
1360                 .id("lyr:demo:geomorfologie")
1361                 .serviceId("demo")
1362                 .layerName("geomorfologie")
1363                 .visible(true)))
1364         .setStyling(new AppStyling().logo(logo.getId().toString()))
1365         .setSettings(new AppSettings()
1366             .putLayerSettingsItem("lyr:openbasiskaart:osm", new AppLayerSettings().title("Openbasiskaart"))
1367             .putLayerSettingsItem(
1368                 "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR", new AppLayerSettings().title("Luchtfoto"))
1369             .putLayerSettingsItem(
1370                 "lyr:openbasiskaart-proxied:osm",
1371                 new AppLayerSettings().title("Openbasiskaart (proxied)"))
1372             .putLayerSettingsItem(
1373                 "lyr:snapshot-geoserver:oracle:WATERDEEL",
1374                 new AppLayerSettings()
1375                     .opacity(50)
1376                     .title("Waterdeel overridden title")
1377                     .editable(true)
1378                     .description("This is the layer description from the app layer setting.")
1379                     .attribution(
1380                         "CC BY 4.0 [BGT/Kadaster](https://www.nationaalgeoregister.nl/geonetwork/srv/api/records/2cb4769c-b56e-48fa-8685-c48f61b9a319)"))
1381             .putLayerSettingsItem(
1382                 "lyr:snapshot-geoserver:postgis:osm_polygon",
1383                 new AppLayerSettings()
1384                     .description("OpenStreetMap polygon data in EPSG:3857")
1385                     .opacity(60)
1386                     .editable(true)
1387                     .title("OSM Polygon (EPSG:3857)")
1388                     .attribution(
1389                         "© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors"))
1390             .putLayerSettingsItem(
1391                 "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1392                 new AppLayerSettings()
1393                     .editable(true)
1394                     .addHideAttributesItem("begroeidterreindeeloptalud")
1395                     .addReadOnlyAttributesItem("eindregistratie")
1396                     .selectedStyles(
1397                         List.of(
1398                             new WMSStyle()
1399                                 .name("begroeidterreindeel")
1400                                 .title("Visualisatie van de begroeide terreindelen")
1401                                 .abstractText(
1402                                     "Deze stylesheet bevat de regels voor de visualisatie van het objecttype Begroeid Terreindeel")
1403                                 .legendUrl(
1404                                     URI.create(
1405                                         "https://snapshot.tailormap.nl/geoserver/ows?service=WMS&version=1.3.0&request=GetLegendGraphic&format=image%2Fpng&width=20&height=20&layer=test%3Apostgis_begroeidterreindeel")),
1406                             new WMSStyle()
1407                                 .name("purple_polygon")
1408                                 .title("purple_polygon")
1409                                 .abstractText(null)
1410                                 .legendUrl(
1411                                     URI.create(
1412                                         "https://snapshot.tailormap.nl/geoserver/ows?service=WMS&version=1.3.0&request=GetLegendGraphic&format=image%2Fpng&width=20&height=20&layer=test%3Apostgis_begroeidterreindeel&style=purple_polygon")))))
1413             .putLayerSettingsItem(
1414                 "lyr:snapshot-geoserver:postgis:kadastraal_perceel",
1415                 new AppLayerSettings().editable(true).addReadOnlyAttributesItem("aanduiding"))
1416             .putLayerSettingsItem(
1417                 "lyr:snapshot-geoserver:sqlserver:wegdeel", new AppLayerSettings().editable(true))
1418             .putLayerSettingsItem(
1419                 "lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel",
1420                 new AppLayerSettings().editable(false))
1421             .putLayerSettingsItem(
1422                 "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1423                 new AppLayerSettings()
1424                     .hiddenFunctionality(Set.of(FEATURE_INFO, ATTRIBUTE_LIST, EXPORT)))
1425             .addFilterGroupsItem(new FilterGroup()
1426                 .id("filtergroup1")
1427                 .source("PRESET")
1428                 .type(FilterGroup.TypeEnum.ATTRIBUTE)
1429                 .layerIds(List.of("lyr:snapshot-geoserver:postgis:begroeidterreindeel"))
1430                 .operator(FilterGroup.OperatorEnum.AND)
1431                 .addFiltersItem(new Filter()
1432                     .id("filter1")
1433                     .type(Filter.TypeEnum.ATTRIBUTE)
1434                     .condition(Filter.ConditionEnum.BEFORE)
1435                     .addValueItem("2025-06-05")
1436                     .attribute("creationdate")
1437                     .attributeType(Filter.AttributeTypeEnum.DATE))
1438                 .addFiltersItem(new Filter()
1439                     .id("filter2")
1440                     .type(Filter.TypeEnum.ATTRIBUTE)
1441                     .condition(Filter.ConditionEnum.UNIQUE_VALUES)
1442                     .addValueItem("bodembedekkers")
1443                     .addValueItem("bosplantsoen")
1444                     .addValueItem("gras- en kruidachtigen")
1445                     .attribute("plus_fysiekvoorkomen")
1446                     .attributeType(Filter.AttributeTypeEnum.STRING)
1447                     .editConfiguration(new FilterEditConfiguration()
1448                         .filterTool(FilterEditConfiguration.FilterToolEnum.CHECKBOX)
1449                         .attributeValuesSettings(List.of(
1450                             new AttributeValueSettings()
1451                                 .value("bodembedekkers")
1452                                 .initiallySelected(true)
1453                                 .selectable(true)
1454                                 .alias("Bodembedekkers"),
1455                             new AttributeValueSettings()
1456                                 .value("bosplantsoen")
1457                                 .initiallySelected(true)
1458                                 .selectable(true)
1459                                 .alias("Bosplantsoen"),
1460                             new AttributeValueSettings()
1461                                 .value("gras- en kruidachtigen")
1462                                 .initiallySelected(true)
1463                                 .selectable(true)
1464                                 .alias("Gras- en kruidachtigen"),
1465                             new AttributeValueSettings()
1466                                 .value("griend en hakhout")
1467                                 .initiallySelected(false)
1468                                 .selectable(true),
1469                             new AttributeValueSettings()
1470                                 .value("heesters")
1471                                 .initiallySelected(false)
1472                                 .selectable(true),
1473                             new AttributeValueSettings()
1474                                 .value("planten")
1475                                 .initiallySelected(false)
1476                                 .selectable(true),
1477                             new AttributeValueSettings()
1478                                 .value("struikrozen")
1479                                 .initiallySelected(false)
1480                                 .selectable(true),
1481                             new AttributeValueSettings()
1482                                 .value("waardeOnbekend")
1483                                 .initiallySelected(false)
1484                                 .selectable(true))))))
1485             .addFilterGroupsItem(new FilterGroup()
1486                 .id("filtergroup2")
1487                 .source("PRESET")
1488                 .type(FilterGroup.TypeEnum.ATTRIBUTE)
1489                 .layerIds(List.of("lyr:snapshot-geoserver:postgis:kadastraal_perceel"))
1490                 .operator(FilterGroup.OperatorEnum.AND)
1491                 .addFiltersItem(new Filter()
1492                     .id("filter3")
1493                     .type(Filter.TypeEnum.ATTRIBUTE)
1494                     .condition(Filter.ConditionEnum.u)
1495                     .addValueItem("1")
1496                     .addValueItem("12419")
1497                     .attribute("perceelnummer")
1498                     .attributeType(Filter.AttributeTypeEnum.DOUBLE)
1499                     .editConfiguration(new FilterEditConfiguration()
1500                         .filterTool(FilterEditConfiguration.FilterToolEnum.SLIDER)
1501                         .initialLowerValue(1d)
1502                         .initialUpperValue(12419d)
1503                         .minimumValue(1d)
1504                         .maximumValue(12419d)))));
1505 
1506     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1507     app.setInitialExtent(
1508         new Bounds().minx(130011d).miny(458031d).maxx(132703d).maxy(459995d));
1509     app.setMaxExtent(new Bounds().minx(-285401d).miny(22598d).maxx(595401d).maxy(903401d));
1510 
1511     if (map5url != null) {
1512       AppTreeLevelNode root =
1513           (AppTreeLevelNode) app.getContentRoot().getBaseLayerNodes().getFirst();
1514       List<String> childrenIds = new ArrayList<>(root.getChildrenIds());
1515       childrenIds.add("lyr:map5:map5topo");
1516       childrenIds.add("lyr:map5:map5topo_simple");
1517       childrenIds.add("lvl:luchtfoto-labels");
1518       root.setChildrenIds(childrenIds);
1519       app.getSettings()
1520           .putLayerSettingsItem("lyr:map5:map5topo", new AppLayerSettings().title("Map5"))
1521           .putLayerSettingsItem("lyr:map5:map5topo_simple", new AppLayerSettings().title("Map5 simple"));
1522       app.getContentRoot()
1523           .addBaseLayerNodesItem(new AppTreeLayerNode()
1524               .objectType("AppTreeLayerNode")
1525               .id("lyr:map5:map5topo")
1526               .serviceId("map5")
1527               .layerName("map5topo")
1528               .visible(false))
1529           .addBaseLayerNodesItem(new AppTreeLayerNode()
1530               .objectType("AppTreeLayerNode")
1531               .id("lyr:map5:map5topo_simple")
1532               .serviceId("map5")
1533               .layerName("map5topo_simple")
1534               .visible(false))
1535           .addBaseLayerNodesItem(new AppTreeLevelNode()
1536               .objectType("AppTreeLevelNode")
1537               .id("lvl:luchtfoto-labels")
1538               .title("Luchtfoto met labels")
1539               .addChildrenIdsItem("lyr:map5:luforoadslabels")
1540               .addChildrenIdsItem("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR2"))
1541           .addBaseLayerNodesItem(new AppTreeLayerNode()
1542               .objectType("AppTreeLayerNode")
1543               .id("lyr:map5:luforoadslabels")
1544               .serviceId("map5")
1545               .layerName("luforoadslabels")
1546               .visible(false))
1547           .addBaseLayerNodesItem(new AppTreeLayerNode()
1548               .objectType("AppTreeLayerNode")
1549               .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR2")
1550               .serviceId("pdok-hwh-luchtfotorgb")
1551               .layerName("Actueel_orthoHR")
1552               .visible(false));
1553     }
1554 
1555     applicationRepository.save(app);
1556 
1557     app = new Application()
1558         .setName("base")
1559         .setTitle("Service base app")
1560         .setCrs("EPSG:28992")
1561         .setAuthorizationRules(ruleAnonymousRead)
1562         .setContentRoot(new AppContent()
1563             .addBaseLayerNodesItem(new AppTreeLevelNode()
1564                 .objectType("AppTreeLevelNode")
1565                 .id("root-base-layers")
1566                 .root(true)
1567                 .title("Base layers")
1568                 .childrenIds(List.of(
1569                     "lyr:openbasiskaart:osm", "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR"))));
1570     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1571     applicationRepository.save(app);
1572 
1573     app = new Application()
1574         .setName("secured")
1575         .setTitle("secured app")
1576         .setCrs("EPSG:28992")
1577         .setAuthorizationRules(ruleLoggedIn)
1578         .setContentRoot(new AppContent()
1579             .addBaseLayerNodesItem(new AppTreeLevelNode()
1580                 .objectType("AppTreeLevelNode")
1581                 .id("root-base-layers")
1582                 .root(true)
1583                 .title("Base layers")
1584                 .childrenIds(List.of(
1585                     "lyr:openbasiskaart:osm",
1586                     "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR",
1587                     "lyr:openbasiskaart-proxied:osm")))
1588             .addBaseLayerNodesItem(new AppTreeLayerNode()
1589                 .objectType("AppTreeLayerNode")
1590                 .id("lyr:openbasiskaart-proxied:osm")
1591                 .serviceId("openbasiskaart-proxied")
1592                 .layerName("osm")
1593                 .visible(false))
1594             .addLayerNodesItem(new AppTreeLevelNode()
1595                 .objectType("AppTreeLevelNode")
1596                 .id("root")
1597                 .root(true)
1598                 .title("Layers")
1599                 .childrenIds(List.of(
1600                     "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1601                     "lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied",
1602                     "lvl:proxied")))
1603             .addLayerNodesItem(new AppTreeLayerNode()
1604                 .objectType("AppTreeLayerNode")
1605                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied")
1606                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1607                 .layerName("Gemeentegebied")
1608                 .visible(true))
1609             .addLayerNodesItem(new AppTreeLayerNode()
1610                 .objectType("AppTreeLayerNode")
1611                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied")
1612                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1613                 .layerName("Provinciegebied")
1614                 .visible(false))
1615             .addLayerNodesItem(new AppTreeLevelNode()
1616                 .objectType("AppTreeLevelNode")
1617                 .id("lvl:proxied")
1618                 .title("Proxied")
1619                 .childrenIds(List.of("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")))
1620             .addLayerNodesItem(new AppTreeLayerNode()
1621                 .objectType("AppTreeLayerNode")
1622                 .id("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")
1623                 .serviceId("snapshot-geoserver-proxied")
1624                 .layerName("postgis:begroeidterreindeel")
1625                 .visible(false)))
1626         .setSettings(
1627             new AppSettings()
1628                 .putLayerSettingsItem(
1629                     "lyr:openbasiskaart-proxied:osm",
1630                     new AppLayerSettings().title("Openbasiskaart (proxied)"))
1631                 .putLayerSettingsItem(
1632                     "lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel",
1633                     new AppLayerSettings()
1634                         .description("This layer should render using purple polygons")
1635                         .selectedStyles(
1636                             List.of(
1637                                 new WMSStyle()
1638                                     .name("purple_polygon")
1639                                     .title("purple_polygon")
1640                                     .abstractText(null)
1641                                     .legendUrl(
1642                                         URI.create(
1643                                             "https://snapshot.tailormap.nl/geoserver/ows?service=WMS&version=1.3.0&request=GetLegendGraphic&format=image%2Fpng&width=20&height=20&layer=test%3Apostgis_begroeidterreindeel&style=purple_polygon"))))));
1644 
1645     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1646     applicationRepository.save(app);
1647 
1648     app = new Application()
1649         .setName("secured-auth")
1650         .setTitle("secured (with authorizations)")
1651         .setCrs("EPSG:28992")
1652         .setAuthorizationRules(List.of(
1653             new AuthorizationRule()
1654                 .groupName("test-foo")
1655                 .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)),
1656             new AuthorizationRule()
1657                 .groupName("test-bar")
1658                 .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW))))
1659         .setContentRoot(new AppContent()
1660             .addLayerNodesItem(new AppTreeLevelNode()
1661                 .objectType("AppTreeLevelNode")
1662                 .id("root")
1663                 .root(true)
1664                 .title("Layers")
1665                 .childrenIds(List.of("lvl:needs-auth", "lvl:public")))
1666             .addLayerNodesItem(new AppTreeLevelNode()
1667                 .objectType("AppTreeLevelNode")
1668                 .id("lvl:public")
1669                 .title("Public")
1670                 .childrenIds(List.of("lyr:snapshot-geoserver:BGT")))
1671             .addLayerNodesItem(new AppTreeLevelNode()
1672                 .objectType("AppTreeLevelNode")
1673                 .id("lvl:needs-auth")
1674                 .title("Needs auth")
1675                 .childrenIds(List.of(
1676                     "lyr:filtered-snapshot-geoserver:BGT",
1677                     "lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")))
1678             .addLayerNodesItem(new AppTreeLayerNode()
1679                 .objectType("AppTreeLayerNode")
1680                 .id("lyr:filtered-snapshot-geoserver:BGT")
1681                 .serviceId("filtered-snapshot-geoserver")
1682                 .layerName("BGT")
1683                 .visible(true))
1684             .addLayerNodesItem(new AppTreeLayerNode()
1685                 .objectType("AppTreeLayerNode")
1686                 .id("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")
1687                 .serviceId("filtered-snapshot-geoserver")
1688                 .layerName("postgis:begroeidterreindeel")
1689                 .visible(true))
1690             .addLayerNodesItem(new AppTreeLayerNode()
1691                 .objectType("AppTreeLayerNode")
1692                 .id("lyr:snapshot-geoserver:BGT")
1693                 .serviceId("snapshot-geoserver")
1694                 .layerName("BGT")
1695                 .visible(true)));
1696 
1697     applicationRepository.save(app);
1698 
1699     app = new Application()
1700         .setName("austria")
1701         .setCrs("EPSG:3857")
1702         .setAuthorizationRules(ruleAnonymousRead)
1703         .setTitle("Austria")
1704         .setInitialExtent(
1705             new Bounds().minx(987982d).miny(5799551d).maxx(1963423d).maxy(6320708d))
1706         .setMaxExtent(
1707             new Bounds().minx(206516d).miny(5095461d).maxx(3146930d).maxy(7096232d))
1708         .setContentRoot(new AppContent()
1709             .addBaseLayerNodesItem(new AppTreeLevelNode()
1710                 .objectType("AppTreeLevelNode")
1711                 .id("root-base-layers")
1712                 .root(true)
1713                 .title("Base layers")
1714                 .childrenIds(List.of(
1715                     "lyr:at-basemap:geolandbasemap",
1716                     "lyr:at-basemap:orthofoto",
1717                     "lvl:orthofoto-labels",
1718                     "lyr:osm:xyz")))
1719             .addBaseLayerNodesItem(new AppTreeLayerNode()
1720                 .objectType("AppTreeLayerNode")
1721                 .id("lyr:at-basemap:geolandbasemap")
1722                 .serviceId("at-basemap")
1723                 .layerName("geolandbasemap")
1724                 .visible(true))
1725             .addBaseLayerNodesItem(new AppTreeLayerNode()
1726                 .objectType("AppTreeLayerNode")
1727                 .id("lyr:at-basemap:orthofoto")
1728                 .serviceId("at-basemap")
1729                 .layerName("bmaporthofoto30cm")
1730                 .visible(false))
1731             .addBaseLayerNodesItem(new AppTreeLevelNode()
1732                 .objectType("AppTreeLevelNode")
1733                 .id("lvl:orthofoto-labels")
1734                 .title("Orthophoto with labels")
1735                 .childrenIds(List.of("lyr:at-basemap:bmapoverlay", "lyr:at-basemap:orthofoto_2")))
1736             .addBaseLayerNodesItem(new AppTreeLayerNode()
1737                 .objectType("AppTreeLayerNode")
1738                 .id("lyr:at-basemap:bmapoverlay")
1739                 .serviceId("at-basemap")
1740                 .layerName("bmapoverlay")
1741                 .visible(false))
1742             .addBaseLayerNodesItem(new AppTreeLayerNode()
1743                 .objectType("AppTreeLayerNode")
1744                 .id("lyr:at-basemap:orthofoto_2")
1745                 .serviceId("at-basemap")
1746                 .layerName("bmaporthofoto30cm")
1747                 .visible(false))
1748             .addBaseLayerNodesItem(new AppTreeLayerNode()
1749                 .objectType("AppTreeLayerNode")
1750                 .id("lyr:osm:xyz")
1751                 .serviceId("osm")
1752                 .layerName("xyz")
1753                 .visible(false)));
1754 
1755     applicationRepository.save(app);
1756 
1757     app = new Application()
1758         .setName("3d_utrecht")
1759         .setCrs("EPSG:3857")
1760         .setAuthorizationRules(ruleLoggedIn)
1761         .setTitle("3D Utrecht")
1762         .setInitialExtent(
1763             new Bounds().minx(558390d).miny(6818485d).maxx(566751d).maxy(6824036d))
1764         .setMaxExtent(
1765             new Bounds().minx(91467d).miny(6496479d).maxx(1037043d).maxy(7147453d))
1766         .setSettings(new AppSettings().uiSettings(new AppUiSettings().enable3D(true)))
1767         .setContentRoot(new AppContent()
1768             .addBaseLayerNodesItem(new AppTreeLevelNode()
1769                 .objectType("AppTreeLevelNode")
1770                 .id("root-base-layers")
1771                 .root(true)
1772                 .title("Base layers")
1773                 .childrenIds(List.of("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR", "lyr:osm:xyz")))
1774             .addBaseLayerNodesItem(new AppTreeLayerNode()
1775                 .objectType("AppTreeLayerNode")
1776                 .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR")
1777                 .serviceId("pdok-hwh-luchtfotorgb")
1778                 .layerName("Actueel_orthoHR")
1779                 .visible(true))
1780             .addBaseLayerNodesItem(new AppTreeLayerNode()
1781                 .objectType("AppTreeLayerNode")
1782                 .id("lyr:osm:xyz")
1783                 .serviceId("osm")
1784                 .layerName("xyz")
1785                 .visible(false))
1786             .addLayerNodesItem(new AppTreeLevelNode()
1787                 .objectType("AppTreeLevelNode")
1788                 .id("root")
1789                 .root(true)
1790                 .title("Layers")
1791                 .childrenIds(List.of(
1792                     "lyr:3dbag_utrecht:tiles3d",
1793                     "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1794                     "lyr:3d_basisvoorziening_gebouwen_proxy:tiles3d",
1795                     "lyr:3d_utrecht_proxied_auth:tiles3d")))
1796             .addLayerNodesItem(new AppTreeLayerNode()
1797                 .objectType("AppTreeLayerNode")
1798                 .id("lyr:3dbag_utrecht:tiles3d")
1799                 .serviceId("3dbag_utrecht")
1800                 .layerName("tiles3d")
1801                 .visible(true))
1802             .addLayerNodesItem(new AppTreeLayerNode()
1803                 .objectType("AppTreeLayerNode")
1804                 .id("lyr:3d_basisvoorziening_gebouwen_proxy:tiles3d")
1805                 .serviceId("3d_basisvoorziening_gebouwen_proxy")
1806                 .layerName("tiles3d")
1807                 .visible(false))
1808             .addLayerNodesItem(new AppTreeLayerNode()
1809                 .objectType("AppTreeLayerNode")
1810                 .id("lyr:3d_utrecht_proxied_auth:tiles3d")
1811                 .serviceId("3d_utrecht_proxied_auth")
1812                 .layerName("tiles3d")
1813                 .visible(false))
1814             .addLayerNodesItem(new AppTreeLayerNode()
1815                 .objectType("AppTreeLayerNode")
1816                 .id("lyr:snapshot-geoserver:postgis:begroeidterreindeel")
1817                 .serviceId("snapshot-geoserver")
1818                 .layerName("postgis:begroeidterreindeel")
1819                 .visible(true))
1820             .addTerrainLayerNodesItem(new AppTreeLevelNode()
1821                 .objectType("AppTreeLevelNode")
1822                 .id("root-terrain-layers")
1823                 .root(true)
1824                 .title("Terrain Layers")
1825                 .childrenIds(List.of("lyr:ahn_terrain_model:quantizedmesh")))
1826             .addTerrainLayerNodesItem(new AppTreeLayerNode()
1827                 .objectType("AppTreeLayerNode")
1828                 .id("lyr:ahn_terrain_model:quantizedmesh")
1829                 .serviceId("ahn_terrain_model")
1830                 .layerName("quantizedmesh")
1831                 .visible(false)));
1832 
1833     applicationRepository.save(app);
1834 
1835     app = new Application()
1836         .setName("public-with-auth")
1837         .setTitle("Public app with one restricted layer in a group")
1838         .setCrs("EPSG:28992")
1839         .setAuthorizationRules(ruleAnonymousRead)
1840         .setInitialExtent(
1841             new Bounds().minx(130011d).miny(458031d).maxx(132703d).maxy(459995d))
1842         .setMaxExtent(
1843             new Bounds().minx(-285401d).miny(22598d).maxx(595401d).maxy(903401d))
1844         .setStyling(new AppStyling().logo(logo.getId().toString()))
1845         .setContentRoot(new AppContent()
1846             .addBaseLayerNodesItem(new AppTreeLevelNode()
1847                 .objectType("AppTreeLevelNode")
1848                 .id("root")
1849                 .root(true)
1850                 .title("Basemaps")
1851                 .childrenIds(List.of("lyr:openbasiskaart:osm")))
1852             .addBaseLayerNodesItem(new AppTreeLayerNode()
1853                 .objectType("AppTreeLayerNode")
1854                 .id("lyr:openbasiskaart:osm")
1855                 .serviceId("openbasiskaart")
1856                 .layerName("osm")
1857                 .visible(true))
1858             .addLayerNodesItem(new AppTreeLevelNode()
1859                 .objectType("AppTreeLevelNode")
1860                 .id("root")
1861                 .root(true)
1862                 .title("Application layers")
1863                 .childrenIds(List.of(
1864                     "lyr:snapshot-geoserver:postgis:kadastraal_perceel", "xpfhl34VmghkU12nP9Jer")))
1865             .addLayerNodesItem(new AppTreeLayerNode()
1866                 .objectType("AppTreeLayerNode")
1867                 .id("lyr:snapshot-geoserver:postgis:kadastraal_perceel")
1868                 .serviceId("snapshot-geoserver")
1869                 .layerName("postgis:kadastraal_perceel")
1870                 .visible(true))
1871             .addLayerNodesItem(new AppTreeLevelNode()
1872                 .id("xpfhl34VmghkU12nP9Jer")
1873                 .root(false)
1874                 .title("restricted")
1875                 .objectType("AppTreeLevelNode")
1876                 .childrenIds(List.of("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")))
1877             .addLayerNodesItem(new AppTreeLayerNode()
1878                 .objectType("AppTreeLayerNode")
1879                 .id("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")
1880                 .visible(true)
1881                 .serviceId("filtered-snapshot-geoserver")
1882                 .layerName("postgis:begroeidterreindeel")))
1883         .setSettings(new AppSettings());
1884 
1885     applicationRepository.save(app);
1886 
1887     Configuration config = new Configuration();
1888     config.setKey(Configuration.DEFAULT_APP);
1889     config.setValue("default");
1890     configurationRepository.save(config);
1891     config = new Configuration();
1892     config.setKey(Configuration.DEFAULT_BASE_APP);
1893     config.setValue("base");
1894     configurationRepository.save(config);
1895   }
1896 
1897   private void createConfigurationTestData() throws JacksonException {
1898     Configuration config = new Configuration();
1899     config.setKey("test");
1900     config.setAvailableForViewer(true);
1901     config.setValue("test value");
1902     config.setJsonValue(new JsonMapper().readTree("{ \"someProperty\": 1, \"nestedObject\": { \"num\": 42 } }"));
1903     configurationRepository.save(config);
1904   }
1905 
1906   @Transactional
1907   public void createSolrIndex() throws Exception {
1908     if (connectToSpatialDbs) {
1909       // flush() the repo because we need to make sure feature type testdata is fully stored
1910       // before creating the Solr index (which requires access to the feature type settings)
1911       featureSourceRepository.flush();
1912 
1913       logger.info("Creating Solr index");
1914       @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
1915       final String solrUrl = "http://" + (connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "solr") + ":8983/solr/";
1916       this.solrService.setSolrUrl(solrUrl);
1917       SolrHelper solrHelper = new SolrHelper(this.solrService.getSolrClientForIndexing())
1918           .withBatchSize(solrBatchSize)
1919           .withGeometryValidationRule(solrGeometryValidationRule);
1920       GeoService geoService =
1921           geoServiceRepository.findById("snapshot-geoserver").orElseThrow();
1922       Application defaultApp = applicationRepository.findByName("default");
1923 
1924       TMFeatureType begroeidterreindeelFT = geoService.findFeatureTypeForLayer(
1925           geoService.findLayer("postgis:begroeidterreindeel"), featureSourceRepository);
1926 
1927       TMFeatureType wegdeelFT = geoService.findFeatureTypeForLayer(
1928           geoService.findLayer("sqlserver:wegdeel"), featureSourceRepository);
1929 
1930       TMFeatureType kadastraalPerceelFT = geoService.findFeatureTypeForLayer(
1931           geoService.findLayer("postgis:kadastraal_perceel"), featureSourceRepository);
1932 
1933       try (solrHelper) {
1934         SearchIndex begroeidterreindeelIndex = null;
1935         if (begroeidterreindeelFT != null) {
1936           begroeidterreindeelIndex = new SearchIndex()
1937               .setName("Begroeidterreindeel")
1938               .setFeatureTypeId(begroeidterreindeelFT.getId())
1939               .setSearchFieldsUsed(List.of("class", "plus_fysiekvoorkomen", "bronhouder"))
1940               .setSearchDisplayFieldsUsed(List.of("class", "plus_fysiekvoorkomen"));
1941           begroeidterreindeelIndex = searchIndexRepository.save(begroeidterreindeelIndex);
1942           begroeidterreindeelIndex = solrHelper.addFeatureTypeIndex(
1943               begroeidterreindeelIndex,
1944               begroeidterreindeelFT,
1945               featureSourceFactoryHelper,
1946               searchIndexRepository);
1947           begroeidterreindeelIndex = searchIndexRepository.save(begroeidterreindeelIndex);
1948         }
1949 
1950         SearchIndex kadastraalPerceelIndex = null;
1951         if (kadastraalPerceelFT != null) {
1952           kadastraalPerceelIndex = new SearchIndex()
1953               .setName("kadastraal_perceel")
1954               .setFeatureTypeId(kadastraalPerceelFT.getId())
1955               .setSearchFieldsUsed(List.of("aanduiding"))
1956               .setSearchDisplayFieldsUsed(List.of("aanduiding"));
1957           kadastraalPerceelIndex = searchIndexRepository.save(kadastraalPerceelIndex);
1958           kadastraalPerceelIndex = solrHelper.addFeatureTypeIndex(
1959               kadastraalPerceelIndex,
1960               kadastraalPerceelFT,
1961               featureSourceFactoryHelper,
1962               searchIndexRepository);
1963           kadastraalPerceelIndex = searchIndexRepository.save(kadastraalPerceelIndex);
1964         }
1965 
1966         SearchIndex wegdeelIndex = null;
1967         if (wegdeelFT != null) {
1968           wegdeelIndex = new SearchIndex()
1969               .setName("Wegdeel")
1970               .setFeatureTypeId(wegdeelFT.getId())
1971               .setSearchFieldsUsed(List.of(
1972                   "function_", "plus_fysiekvoorkomenwegdeel", "surfacematerial", "bronhouder"))
1973               .setSearchDisplayFieldsUsed(List.of("function_", "plus_fysiekvoorkomenwegdeel"));
1974           wegdeelIndex = searchIndexRepository.save(wegdeelIndex);
1975           wegdeelIndex = solrHelper.addFeatureTypeIndex(
1976               wegdeelIndex, wegdeelFT, featureSourceFactoryHelper, searchIndexRepository);
1977           wegdeelIndex = searchIndexRepository.save(wegdeelIndex);
1978         }
1979 
1980         featureSourceRepository
1981             .getByTitle("PostGIS")
1982             .flatMap(fs -> fs.getFeatureTypes().stream()
1983                 .filter(ft -> ft.getName().equals("bak"))
1984                 .findFirst())
1985             .ifPresent(ft -> {
1986               SearchIndex bak = new SearchIndex()
1987                   .setName("bak")
1988                   .setFeatureTypeId(ft.getId())
1989                   .setSearchFieldsUsed(List.of("gmlid", "identificatie", "plus_type"))
1990                   .setSearchDisplayFieldsUsed(List.of("gmlid", "plus_type", "bronhouder"));
1991               searchIndexRepository.save(bak);
1992               try {
1993                 bak = solrHelper.addFeatureTypeIndex(
1994                     bak, ft, featureSourceFactoryHelper, searchIndexRepository);
1995                 searchIndexRepository.save(bak);
1996               } catch (IOException | SolrServerException e) {
1997                 throw new RuntimeException(e);
1998               }
1999             });
2000 
2001         // creating a solr index of config this will/should fail because there is no primary key in
2002         // the FT
2003         featureSourceRepository
2004             .getByTitle("PostGIS OSM")
2005             .flatMap(fs -> fs.getFeatureTypes().stream()
2006                 .filter(ft -> ft.getName().equals("osm_roads"))
2007                 .findFirst())
2008             .ifPresent(ft -> {
2009               SearchIndex osm_no_pk = new SearchIndex()
2010                   .setName("osm_no_pk")
2011                   .setFeatureTypeId(ft.getId())
2012                   .setSearchFieldsUsed(List.of("landuse", "osm_id", "natural", "boundary"))
2013                   .setSearchDisplayFieldsUsed(
2014                       List.of("landuse", "osm_id", "natural", "amenity", "boundary"));
2015               searchIndexRepository.save(osm_no_pk);
2016             });
2017 
2018         AppTreeLayerNode begroeidTerreindeelLayerNode = defaultApp
2019             .getAllAppTreeLayerNode()
2020             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:postgis:begroeidterreindeel"))
2021             .findFirst()
2022             .orElse(null);
2023 
2024         if (begroeidTerreindeelLayerNode != null && begroeidterreindeelIndex != null) {
2025           defaultApp
2026               .getAppLayerSettings(begroeidTerreindeelLayerNode)
2027               .setSearchIndexId(begroeidterreindeelIndex.getId());
2028         }
2029 
2030         AppTreeLayerNode kadastraalPerceelLayerNode = defaultApp
2031             .getAllAppTreeLayerNode()
2032             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:postgis:kadastraal_perceel"))
2033             .findFirst()
2034             .orElse(null);
2035 
2036         if (kadastraalPerceelLayerNode != null && kadastraalPerceelIndex != null) {
2037           defaultApp
2038               .getAppLayerSettings(kadastraalPerceelLayerNode)
2039               .setSearchIndexId(kadastraalPerceelIndex.getId());
2040         }
2041 
2042         AppTreeLayerNode wegdeel = defaultApp
2043             .getAllAppTreeLayerNode()
2044             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:sqlserver:wegdeel"))
2045             .findFirst()
2046             .orElse(null);
2047 
2048         if (wegdeel != null && wegdeelIndex != null) {
2049           defaultApp.getAppLayerSettings(wegdeel).setSearchIndexId(wegdeelIndex.getId());
2050         }
2051 
2052         applicationRepository.save(defaultApp);
2053       }
2054     }
2055   }
2056 
2057   private void createSearchIndexTasks() {
2058     logger.info("Creating search index tasks");
2059     List.of("Begroeidterreindeel", "kadastraal_perceel")
2060         .forEach(name -> searchIndexRepository.findByName(name).ifPresent(index -> {
2061           index.setSchedule(new TaskSchedule()
2062               /* hour */
2063               .cronExpression("0 0 0/1 1/1 * ? *")
2064               // /* 15 min */
2065               // .cronExpression("0 0/15 * 1/1 * ? *")
2066               .description("Update Solr index \"" + name + "\" every hour"));
2067           try {
2068             final UUID uuid = taskManagerService.createTask(
2069                 IndexTask.class,
2070                 new TMJobDataMap(Map.of(
2071                     Task.TYPE_KEY,
2072                     TaskType.INDEX,
2073                     Task.DESCRIPTION_KEY,
2074                     index.getSchedule().getDescription(),
2075                     IndexTask.INDEX_KEY,
2076                     index.getId().toString(),
2077                     Task.PRIORITY_KEY,
2078                     10)),
2079                 index.getSchedule().getCronExpression());
2080 
2081             index.getSchedule().setUuid(uuid);
2082             searchIndexRepository.save(index);
2083 
2084             logger.info("Created task to update Solr index with key: {}", uuid);
2085           } catch (SchedulerException e) {
2086             logger.error("Error creating scheduled solr index task", e);
2087           }
2088         }));
2089   }
2090 
2091   private void createUploads() throws IOException {
2092     for (int i = 0; i < 4; i++) {
2093       // hash b0c2a7e5059c831c289505750defcf53edac5461 will be deleted at the end of the testsuite
2094       uploadRepository.save(new Upload()
2095           .setCategory(UploadCategory.UNRESTRICTED)
2096           .setFilename("upload" + i + ".png")
2097           .setMimeType("image/png")
2098           .setContent(new ClassPathResource("test/upload.png").getContentAsByteArray())
2099           .setLastModified(OffsetDateTime.now(ZoneId.systemDefault())));
2100     }
2101   }
2102 
2103   private void createPages() throws IOException {
2104     Upload logo = new Upload()
2105         .setCategory(UploadCategory.PORTAL_IMAGE)
2106         .setFilename("gradient-portal.svg")
2107         .setMimeType("image/svg+xml")
2108         .setContent(new ClassPathResource("test/gradient-logo.svg").getContentAsByteArray())
2109         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2110     uploadRepository.save(logo);
2111 
2112     Page loggedIn = new Page();
2113     loggedIn.setAuthorizationRules(ruleLoggedIn);
2114     loggedIn.setName("loggedIn");
2115     loggedIn.setType("page");
2116     loggedIn.setContent("About Tailormap");
2117     loggedIn.setContent("""
2118 # About Tailormap
2119 This is a page for logged in users.
2120 """);
2121     pageRepository.save(loggedIn);
2122 
2123     Page about = new Page();
2124     about.setAuthorizationRules(ruleAnonymousRead);
2125     about.setName("about");
2126     about.setType("page");
2127     about.setContent("About Tailormap");
2128     about.setContent("""
2129 # About Tailormap
2130 
2131 This is a page about *Tailormap*. It doesn't say much yet.
2132 """);
2133     pageRepository.save(about);
2134 
2135     Page page = new Page();
2136     page.setAuthorizationRules(ruleAnonymousRead);
2137     page.setName("home");
2138     page.setType("page");
2139     page.setTitle("Tailormap - Home");
2140     page.setContent("""
2141 # Welcome to Tailormap!
2142 
2143 This page is only visible when you implement a frontend to display pages, or get it (including a simple CMS)
2144 from [B3Partners](https://www.b3partners.nl)!
2145 """);
2146     page.setClassName(null);
2147     page.setTiles(List.of(
2148         new PageTile()
2149             .authorizationRules(ruleAnonymousRead)
2150             .id(UUID.randomUUID().toString())
2151             .title("Default app")
2152             .tileType(PageTile.TileTypeEnum.APPLICATION)
2153             .applicationId(Optional.ofNullable(applicationRepository.findByName("default"))
2154                 .map(Application::getId)
2155                 .orElse(null))
2156             .image(logo.getId().toString())
2157             .content("*Default app* tile content")
2158             .filterRequireAuthorization(false)
2159             .openInNewWindow(false),
2160         new PageTile()
2161             .authorizationRules(ruleAnonymousRead)
2162             .id(UUID.randomUUID().toString())
2163             .title("Secured app")
2164             .tileType(PageTile.TileTypeEnum.APPLICATION)
2165             .applicationId(Optional.ofNullable(applicationRepository.findByName("secured"))
2166                 .map(Application::getId)
2167                 .orElse(null))
2168             .filterRequireAuthorization(true)
2169             .content("Secure app, only shown if user has authorization")
2170             .openInNewWindow(false),
2171         new PageTile()
2172             .authorizationRules(ruleAnonymousRead)
2173             .id(UUID.randomUUID().toString())
2174             .title("Secured app (unfiltered)")
2175             .tileType(PageTile.TileTypeEnum.APPLICATION)
2176             .applicationId(Optional.ofNullable(applicationRepository.findByName("secured"))
2177                 .map(Application::getId)
2178                 .orElse(null))
2179             .filterRequireAuthorization(false)
2180             .content("Secure app, tile shown to everyone")
2181             .openInNewWindow(false),
2182         new PageTile()
2183             .authorizationRules(ruleAnonymousRead)
2184             .id(UUID.randomUUID().toString())
2185             .title("About")
2186             .tileType(PageTile.TileTypeEnum.PAGE)
2187             .pageId(about.getId())
2188             .openInNewWindow(false),
2189         new PageTile()
2190             .authorizationRules(ruleAnonymousRead)
2191             .id(UUID.randomUUID().toString())
2192             .title("B3Partners")
2193             .tileType(PageTile.TileTypeEnum.URL)
2194             .url("https://www.b3partners.nl/")
2195             .openInNewWindow(true),
2196         new PageTile()
2197             .authorizationRules(ruleLoggedIn)
2198             .id(UUID.randomUUID().toString())
2199             .title("Github repository")
2200             .tileType(PageTile.TileTypeEnum.URL)
2201             .url("https://github.com/Tailormap/tailormap-viewer")
2202             .openInNewWindow(true),
2203         new PageTile()
2204             .authorizationRules(ruleAnonymousRead)
2205             .id(UUID.randomUUID().toString())
2206             .tileType(PageTile.TileTypeEnum.PAGE)
2207             .title("Secured page")
2208             .pageId(loggedIn.getId())
2209             .filterRequireAuthorization(true)
2210             .openInNewWindow(true),
2211         new PageTile()
2212             .authorizationRules(ruleAnonymousRead)
2213             .id(UUID.randomUUID().toString())
2214             .tileType(PageTile.TileTypeEnum.PAGE)
2215             .title("Secured page (unfiltered)")
2216             .pageId(loggedIn.getId())
2217             .filterRequireAuthorization(false)
2218             .openInNewWindow(true)));
2219     pageRepository.save(page);
2220 
2221     Configuration c = new Configuration();
2222     c.setKey(HOME_PAGE);
2223     c.setValue(page.getId().toString());
2224     configurationRepository.save(c);
2225 
2226     List<MenuItem> globalMenuItems = List.of(
2227         new MenuItem().pageId(about.getId()).label("About").openInNewWindow(false),
2228         new MenuItem()
2229             .label("B3Partners website")
2230             .url("https://www.b3partners.nl/")
2231             .openInNewWindow(true)
2232             .exclusiveOnPageId(about.getId()));
2233     c = new Configuration();
2234     c.setKey(PORTAL_MENU);
2235     c.setJsonValue(new JsonMapper().valueToTree(globalMenuItems));
2236     configurationRepository.save(c);
2237   }
2238 
2239   private void insertTestDrawing() {
2240     // note that the drawing uuid is hardcoded and used in the DrawingControllerIntegrationTest
2241     try {
2242       this.jdbcClient.sql("""
2243 INSERT INTO data.drawing (id,name,description,domain_data,"access",created_by,created_at,updated_by,updated_at,srid,"version") VALUES
2244 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid,'Testcase','A private access drawing that is inserted as part of the testdata','{"items": 1, "domain": "test drawings"}','private','tm-admin','2025-02-27 17:53:36.095164+01','tm-admin','2025-02-27 17:54:19.384961+01',28992,1);
2245 """).update();
2246 
2247       this.jdbcClient.sql("""
2248 INSERT INTO data.drawing_feature (drawing_id,id,geometry,properties) VALUES
2249 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid, '394e0d4a-b374-4d00-94c8-1758ea70e9d9'::uuid, 'SRID=28992;POLYGON ((131982.01 458929.27, 131957.31 458755.45, 132072.39 458736.69, 132099.06 458910.51, 131982.01 458929.27))'::public.geometry, '{"type": "POLYGON", "style": {"label": "", "marker": "circle", "fillColor": "rgb(117, 117, 117)", "labelSize": 15, "labelColor": "rgb(0, 0, 0)", "markerSize": 5, "fillOpacity": 30, "strokeColor": "rgb(98, 54, 255)", "strokeWidth": 3, "strokeOpacity": 100, "markerRotation": 0, "markerFillColor": "rgb(98, 54, 255)", "labelOutlineColor": "rgb(189, 189, 189)", "markerStrokeColor": "rgb(98, 54, 255)", "markerStrokeWidth": 1}}'::jsonb),
2250 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid, '89fc320c-41a3-4635-8d98-39d822339692'::uuid, 'SRID=28992;POINT (131897.55 458971.24)'::public.geometry, '{"type": "CIRCLE", "style": {"label": "CIRCLE", "marker": "circle", "fillColor": "rgb(117, 117, 117)", "labelSize": 15, "labelColor": "rgb(0, 0, 0)", "markerSize": 5, "fillOpacity": 30, "strokeColor": "rgb(98, 54, 255)", "strokeWidth": 3, "strokeOpacity": 100, "markerRotation": 0, "markerFillColor": "rgb(98, 54, 255)", "labelOutlineColor": "rgb(189, 189, 189)", "markerStrokeColor": "rgb(98, 54, 255)", "markerStrokeWidth": 1}, "radius": 14.989999999990687}'::jsonb),
2251 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid, '5d6252cc-25b6-4a43-9053-0779b86895a7'::uuid, 'SRID=28992;LINESTRING (131920.76 458754.96, 131923.73 458783.1, 131930.15 458793.97, 131945.95 458912.98, 131967.68 458946.06)'::public.geometry, '{"type": "LINE", "style": {"label": "", "marker": "circle", "fillColor": "rgb(255, 82, 82)", "labelSize": 15, "labelColor": "rgb(0, 0, 0)", "markerSize": 5, "fillOpacity": 30, "strokeColor": "rgb(255, 82, 82)", "strokeWidth": 13, "strokeOpacity": 100, "markerRotation": 0, "markerFillColor": "rgb(98, 54, 255)", "labelOutlineColor": "rgb(189, 189, 189)", "markerStrokeColor": "rgb(98, 54, 255)", "markerStrokeWidth": 1}}'::jsonb)
2252 """).update();
2253     } catch (Exception any) {
2254       logger.error("Error inserting test drawing in data schema, some tests may fail", any);
2255     }
2256   }
2257 
2258   private void insertDrawingStyle() throws IOException {
2259     Upload upload = new Upload()
2260         .setCategory(UploadCategory.DRAWING_STYLE_IMAGE)
2261         .setMimeType("image/svg+xml")
2262         .setFilename("ISO_7001_PI_PF_007.svg")
2263         .setContent(new ClassPathResource("test/ISO_7001_PI_PF_007.svg").getContentAsByteArray())
2264         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2265     upload = uploadRepository.save(upload);
2266     UUID drinkwaterImageId = upload.getId();
2267 
2268     upload = new Upload()
2269         .setCategory(UploadCategory.DRAWING_STYLE_IMAGE)
2270         .setMimeType("image/svg+xml")
2271         .setFilename("lichtpunt.svg")
2272         .setContent(new ClassPathResource("test/lichtpunt.svg").getContentAsByteArray())
2273         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2274     upload = uploadRepository.save(upload);
2275     UUID lichtpuntImageId = upload.getId();
2276 
2277     upload = new Upload()
2278         .setCategory(UploadCategory.DRAWING_STYLE_IMAGE)
2279         .setMimeType("image/svg+xml")
2280         .setFilename("ISO_7010_E003_-_First_aid_sign.svg")
2281         .setContent(new ClassPathResource("test/ISO_7010_E003_-_First_aid_sign.svg").getContentAsByteArray())
2282         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2283     upload = uploadRepository.save(upload);
2284     UUID firstAidImageId = upload.getId();
2285 
2286     Properties props = new Properties();
2287     props.putAll(Map.of(
2288         "water-uuid", drinkwaterImageId.toString(),
2289         "lichtpunt-uuid", lichtpuntImageId.toString(),
2290         "first-aid-uuid", firstAidImageId.toString()));
2291 
2292     String drawingStyles =
2293         new ClassPathResource("test/object-drawing-style.json").getContentAsString(StandardCharsets.UTF_8);
2294     drawingStyles = new PropertyPlaceholderHelper("${", "}").replacePlaceholders(drawingStyles, props);
2295 
2296     // save the updated drawing style
2297     upload = new Upload()
2298         .setCategory(UploadCategory.DRAWING_STYLE)
2299         .setMimeType("application/json")
2300         .setFilename("object-drawing-style.json")
2301         .setContent(drawingStyles.getBytes(StandardCharsets.UTF_8))
2302         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
2303     uploadRepository.save(upload);
2304   }
2305 }